do a proper canton 3.x code drop in the canton-3x directory (#17980)

* do a proper canton 3.x code drop in the canton-3x directory

* copy the code from canton3

* address Gary's comments

* fix canton-3x
This commit is contained in:
Paul Brauner 2023-12-06 10:47:47 +01:00 committed by GitHub
parent aa67b0bf30
commit 56018b5d6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3398 changed files with 488903 additions and 34 deletions

View File

@ -7,4 +7,4 @@ language-support/ts/node_modules/
language-support/ts/packages/node_modules/
navigator/frontend/node_modules/
compatibility/
canton-3x/

View File

@ -223,7 +223,7 @@ jobs:
set -euo pipefail
git fetch
git checkout origin/main
ci/build-canton-3x.sh HEAD
ci/build-canton-3x.sh
env:
GITHUB_TOKEN: $(CANTON_READONLY_TOKEN)
- template: ci/tell-slack-failed.yml

View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,69 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client
import cats.data.EitherT
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand
import com.digitalasset.canton.ledger.api.auth.client.LedgerCallCredentials
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.networking.grpc.CantonGrpcUtil
import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc}
import com.digitalasset.canton.util.LoggerUtil
import io.grpc.ManagedChannel
import io.grpc.stub.AbstractStub
import scala.concurrent.duration.Duration
import scala.concurrent.{ExecutionContext, Future}
/** Run a command using the default workflow
*/
class GrpcCtlRunner(
maxRequestDebugLines: Int,
maxRequestDebugStringLength: Int,
val loggerFactory: NamedLoggerFactory,
) extends NamedLogging {
/** Runs a command
* @return Either a printable error as a String or a Unit indicating all was successful
*/
def run[Req, Res, Result](
instanceName: String,
command: GrpcAdminCommand[Req, Res, Result],
channel: ManagedChannel,
token: Option[String],
timeout: Duration,
)(implicit ec: ExecutionContext, traceContext: TraceContext): EitherT[Future, String, Result] = {
val baseService: command.Svc = command
.createService(channel)
.withInterceptors(TraceContextGrpc.clientInterceptor)
val service = token.fold(baseService)(LedgerCallCredentials.authenticatingStub(baseService, _))
for {
request <- EitherT.fromEither[Future](command.createRequest())
response <- submitRequest(command)(instanceName, service, request, timeout)
result <- EitherT.fromEither[Future](command.handleResponse(response))
} yield result
}
private def submitRequest[Svc <: AbstractStub[Svc], Req, Res, Result](
command: GrpcAdminCommand[Req, Res, Result]
)(instanceName: String, service: command.Svc, request: Req, timeout: Duration)(implicit
ec: ExecutionContext,
traceContext: TraceContext,
): EitherT[Future, String, Res] =
CantonGrpcUtil
.sendGrpcRequest(service, instanceName)(
command.submitRequest(_, request),
LoggerUtil.truncateString(maxRequestDebugLines, maxRequestDebugStringLength)(
command.toString
),
timeout,
logger,
CantonGrpcUtil.silentLogPolicy, // silent log policy, as the ConsoleEnvironment will log the result
_ => false, // no retry to optimize for low latency
)
.leftMap(_.toString)
}

View File

@ -0,0 +1,138 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import com.digitalasset.canton.DiscardOps
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultBoundedTimeout,
TimeoutType,
}
import com.digitalasset.canton.config.NonNegativeDuration
import io.grpc.stub.{AbstractStub, StreamObserver}
import io.grpc.{Context, ManagedChannel, Status, StatusException, StatusRuntimeException}
import java.util.concurrent.{ScheduledExecutorService, TimeUnit}
import scala.collection.mutable.ListBuffer
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{Future, Promise, blocking}
trait AdminCommand[Req, Res, Result] {
/** Create the request from configured options
*/
def createRequest(): Either[String, Req]
/** Handle the response the service has provided
*/
def handleResponse(response: Res): Either[String, Result]
/** Determines within which time frame the request should complete
*
* Some requests can run for a very long time. In this case, they should be "unbounded".
* For other requests, you will want to set a custom timeout apart from the global default bounded timeout
*/
def timeoutType: TimeoutType = DefaultBoundedTimeout
/** Command's full name used to identify command in logging and span reporting
*/
def fullName: String =
// not using getClass.getSimpleName because it ignores the hierarchy of nested classes, and it also throws unexpected exceptions
getClass.getName.split('.').last.replace("$", ".")
}
/** cantonctl GRPC Command
*/
trait GrpcAdminCommand[Req, Res, Result] extends AdminCommand[Req, Res, Result] {
type Svc <: AbstractStub[Svc]
/** Create the GRPC service to call
*/
def createService(channel: ManagedChannel): Svc
/** Submit the created request to our service
*/
def submitRequest(service: Svc, request: Req): Future[Res]
}
object GrpcAdminCommand {
sealed trait TimeoutType extends Product with Serializable
/** Custom timeout triggered by the client */
final case class CustomClientTimeout(timeout: NonNegativeDuration) extends TimeoutType
/** The Server will ensure the operation is timed out so the client timeout is set to an infinite value */
case object ServerEnforcedTimeout extends TimeoutType
case object DefaultBoundedTimeout extends TimeoutType
case object DefaultUnboundedTimeout extends TimeoutType
object GrpcErrorStatus {
def unapply(ex: Throwable): Option[Status] = ex match {
case e: StatusException => Some(e.getStatus)
case re: StatusRuntimeException => Some(re.getStatus)
case _ => None
}
}
private[digitalasset] def streamedResponse[Request, Response, Result](
service: (Request, StreamObserver[Response]) => Unit,
extract: Response => Seq[Result],
request: Request,
expected: Int,
timeout: FiniteDuration,
scheduler: ScheduledExecutorService,
): Future[Seq[Result]] = {
val promise = Promise[Seq[Result]]()
val buffer = ListBuffer[Result]()
val context = Context.ROOT.withCancellation()
def success(): Unit = blocking(buffer.synchronized {
context.close()
promise.trySuccess(buffer.toList).discard[Boolean]
})
context.run(() =>
service(
request,
new StreamObserver[Response]() {
override def onNext(value: Response): Unit = {
val extracted = extract(value)
blocking(buffer.synchronized {
if (buffer.lengthCompare(expected) < 0) {
buffer ++= extracted
if (buffer.lengthCompare(expected) >= 0) {
success()
}
}
})
}
override def onError(t: Throwable): Unit = {
t match {
case GrpcErrorStatus(status) if status.getCode == Status.CANCELLED.getCode =>
success()
case _ =>
val _ = promise.tryFailure(t)
}
}
override def onCompleted(): Unit = {
success()
}
},
)
)
scheduler.schedule(
new Runnable() {
override def run(): Unit = {
val _ = context.cancel(Status.CANCELLED.asException())
}
},
timeout.toMillis,
TimeUnit.MILLISECONDS,
)
promise.future
}
}

View File

@ -0,0 +1,81 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters as StaticDomainParametersConfig
import com.digitalasset.canton.domain.admin.v0 as adminproto
import com.digitalasset.canton.domain.service.ServiceAgreementAcceptance
import com.digitalasset.canton.protocol.StaticDomainParameters as StaticDomainParametersInternal
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
object DomainAdminCommands {
abstract class BaseDomainServiceCommand[Req, Rep, Res] extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = adminproto.DomainServiceGrpc.DomainServiceStub
override def createService(
channel: ManagedChannel
): adminproto.DomainServiceGrpc.DomainServiceStub =
adminproto.DomainServiceGrpc.stub(channel)
}
final case object ListAcceptedServiceAgreements
extends BaseDomainServiceCommand[Empty, adminproto.ServiceAgreementAcceptances, Seq[
ServiceAgreementAcceptance
]] {
override def createRequest(): Either[String, Empty] = Right(Empty())
override def submitRequest(
service: adminproto.DomainServiceGrpc.DomainServiceStub,
request: Empty,
): Future[adminproto.ServiceAgreementAcceptances] =
service.listServiceAgreementAcceptances(request)
override def handleResponse(
response: adminproto.ServiceAgreementAcceptances
): Either[String, Seq[ServiceAgreementAcceptance]] =
response.acceptances
.traverse(ServiceAgreementAcceptance.fromProtoV0)
.bimap(_.toString, _.toSeq)
}
final case class GetDomainParameters()
extends BaseDomainServiceCommand[
adminproto.GetDomainParameters.Request,
adminproto.GetDomainParameters.Response,
StaticDomainParametersConfig,
] {
override def createRequest(): Either[String, adminproto.GetDomainParameters.Request] = Right(
adminproto.GetDomainParameters.Request()
)
override def submitRequest(
service: adminproto.DomainServiceGrpc.DomainServiceStub,
request: adminproto.GetDomainParameters.Request,
): Future[adminproto.GetDomainParameters.Response] =
service.getDomainParametersVersioned(adminproto.GetDomainParameters.Request())
override def handleResponse(
response: adminproto.GetDomainParameters.Response
): Either[String, StaticDomainParametersConfig] = {
import adminproto.GetDomainParameters.Response.Parameters
response.parameters match {
case Parameters.Empty => Left("Field parameters was not found in the response")
case Parameters.ParametersV1(parametersV1) =>
(for {
staticDomainParametersInternal <- StaticDomainParametersInternal.fromProtoV1(
parametersV1
)
staticDomainParametersConfig = StaticDomainParametersConfig(
staticDomainParametersInternal
)
} yield staticDomainParametersConfig).leftMap(_.toString)
}
}
}
}

View File

@ -0,0 +1,75 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
CustomClientTimeout,
TimeoutType,
}
import com.digitalasset.canton.config.NonNegativeDuration
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.domain.api.v0
import com.digitalasset.canton.domain.api.v0.DomainTimeServiceGrpc.DomainTimeServiceStub
import com.digitalasset.canton.time.{
AwaitTimeRequest,
FetchTimeRequest,
FetchTimeResponse,
NonNegativeFiniteDuration,
}
import com.digitalasset.canton.topology.DomainId
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
object DomainTimeCommands {
abstract class BaseDomainTimeCommand[Req, Rep, Res] extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = DomainTimeServiceStub
override def createService(channel: ManagedChannel): DomainTimeServiceStub =
v0.DomainTimeServiceGrpc.stub(channel)
}
final case class FetchTime(
domainIdO: Option[DomainId],
freshnessBound: NonNegativeFiniteDuration,
timeout: NonNegativeDuration,
) extends BaseDomainTimeCommand[FetchTimeRequest, v0.FetchTimeResponse, FetchTimeResponse] {
override def createRequest(): Either[String, FetchTimeRequest] =
Right(FetchTimeRequest(domainIdO, freshnessBound))
override def submitRequest(
service: DomainTimeServiceStub,
request: FetchTimeRequest,
): Future[v0.FetchTimeResponse] =
service.fetchTime(request.toProtoV0)
override def handleResponse(response: v0.FetchTimeResponse): Either[String, FetchTimeResponse] =
FetchTimeResponse.fromProto(response).leftMap(_.toString)
override def timeoutType: TimeoutType = CustomClientTimeout(timeout)
}
final case class AwaitTime(
domainIdO: Option[DomainId],
time: CantonTimestamp,
timeout: NonNegativeDuration,
) extends BaseDomainTimeCommand[AwaitTimeRequest, Empty, Unit] {
override def createRequest(): Either[String, AwaitTimeRequest] =
Right(AwaitTimeRequest(domainIdO, time))
override def submitRequest(
service: DomainTimeServiceStub,
request: AwaitTimeRequest,
): Future[Empty] =
service.awaitTime(request.toProtoV0)
override def handleResponse(response: Empty): Either[String, Unit] = Right(())
override def timeoutType: TimeoutType = CustomClientTimeout(timeout)
}
}

View File

@ -0,0 +1,175 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.option.*
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultUnboundedTimeout,
TimeoutType,
}
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.crypto.{Fingerprint, PublicKey}
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.domain.admin.v0.EnterpriseMediatorAdministrationServiceGrpc
import com.digitalasset.canton.domain.admin.{v0, v2}
import com.digitalasset.canton.domain.mediator.admin.gprc.{
InitializeMediatorRequest,
InitializeMediatorRequestX,
InitializeMediatorResponse,
InitializeMediatorResponseX,
}
import com.digitalasset.canton.protocol.StaticDomainParameters
import com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp
import com.digitalasset.canton.sequencing.SequencerConnections
import com.digitalasset.canton.topology.store.StoredTopologyTransactions
import com.digitalasset.canton.topology.transaction.TopologyChangeOp
import com.digitalasset.canton.topology.{DomainId, MediatorId}
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
object EnterpriseMediatorAdministrationCommands {
abstract class BaseMediatorInitializationCommand[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = v0.MediatorInitializationServiceGrpc.MediatorInitializationServiceStub
override def createService(
channel: ManagedChannel
): v0.MediatorInitializationServiceGrpc.MediatorInitializationServiceStub =
v0.MediatorInitializationServiceGrpc.stub(channel)
}
abstract class BaseMediatorXInitializationCommand[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = v2.MediatorInitializationServiceGrpc.MediatorInitializationServiceStub
override def createService(
channel: ManagedChannel
): v2.MediatorInitializationServiceGrpc.MediatorInitializationServiceStub =
v2.MediatorInitializationServiceGrpc.stub(channel)
}
abstract class BaseMediatorAdministrationCommand[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc =
v0.EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub
override def createService(
channel: ManagedChannel
): v0.EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub =
v0.EnterpriseMediatorAdministrationServiceGrpc.stub(channel)
}
final case class Initialize(
domainId: DomainId,
mediatorId: MediatorId,
topologyState: Option[StoredTopologyTransactions[TopologyChangeOp.Positive]],
domainParameters: StaticDomainParameters,
sequencerConnections: SequencerConnections,
signingKeyFingerprint: Option[Fingerprint],
) extends BaseMediatorInitializationCommand[
v0.InitializeMediatorRequest,
v0.InitializeMediatorResponse,
PublicKey,
] {
override def createRequest(): Either[String, v0.InitializeMediatorRequest] =
Right(
InitializeMediatorRequest(
domainId,
mediatorId,
topologyState,
domainParameters,
sequencerConnections,
signingKeyFingerprint,
).toProtoV0
)
override def submitRequest(
service: v0.MediatorInitializationServiceGrpc.MediatorInitializationServiceStub,
request: v0.InitializeMediatorRequest,
): Future[v0.InitializeMediatorResponse] =
service.initialize(request)
override def handleResponse(
response: v0.InitializeMediatorResponse
): Either[String, PublicKey] =
InitializeMediatorResponse
.fromProtoV0(response)
.leftMap(err => s"Failed to deserialize response: $err")
.flatMap(_.toEither)
}
final case class InitializeX(
domainId: DomainId,
domainParameters: StaticDomainParameters,
sequencerConnections: SequencerConnections,
) extends BaseMediatorXInitializationCommand[
v2.InitializeMediatorRequest,
v2.InitializeMediatorResponse,
Unit,
] {
override def createRequest(): Either[String, v2.InitializeMediatorRequest] =
Right(
InitializeMediatorRequestX(
domainId,
domainParameters,
sequencerConnections,
).toProtoV2
)
override def submitRequest(
service: v2.MediatorInitializationServiceGrpc.MediatorInitializationServiceStub,
request: v2.InitializeMediatorRequest,
): Future[v2.InitializeMediatorResponse] =
service.initialize(request)
override def handleResponse(
response: v2.InitializeMediatorResponse
): Either[String, Unit] =
InitializeMediatorResponseX
.fromProtoV2(response)
.leftMap(err => s"Failed to deserialize response: $err")
.map(_ => ())
}
final case class Prune(timestamp: CantonTimestamp)
extends GrpcAdminCommand[v0.MediatorPruningRequest, Empty, Unit] {
override type Svc =
v0.EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub
override def createService(
channel: ManagedChannel
): v0.EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub =
v0.EnterpriseMediatorAdministrationServiceGrpc.stub(channel)
override def createRequest(): Either[String, v0.MediatorPruningRequest] =
Right(v0.MediatorPruningRequest(timestamp.toProtoPrimitive.some))
override def submitRequest(
service: v0.EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub,
request: v0.MediatorPruningRequest,
): Future[Empty] = service.prune(request)
override def handleResponse(response: Empty): Either[String, Unit] = Right(())
// all pruning commands will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class LocatePruningTimestampCommand(index: PositiveInt)
extends BaseMediatorAdministrationCommand[
LocatePruningTimestamp.Request,
LocatePruningTimestamp.Response,
Option[CantonTimestamp],
] {
override def createRequest(): Either[String, LocatePruningTimestamp.Request] = Right(
LocatePruningTimestamp.Request(index.value)
)
override def submitRequest(
service: EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub,
request: LocatePruningTimestamp.Request,
): Future[LocatePruningTimestamp.Response] =
service.locatePruningTimestamp(request)
override def handleResponse(
response: LocatePruningTimestamp.Response
): Either[String, Option[CantonTimestamp]] =
response.timestamp.fold(Right(None): Either[String, Option[CantonTimestamp]])(
CantonTimestamp.fromProtoPrimitive(_).bimap(_.message, Some(_))
)
}
}

View File

@ -0,0 +1,289 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.option.*
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultUnboundedTimeout,
TimeoutType,
}
import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.domain.admin.v2.SequencerInitializationServiceGrpc
import com.digitalasset.canton.domain.admin.{v0, v2}
import com.digitalasset.canton.domain.sequencing.admin.grpc.{
InitializeSequencerRequest,
InitializeSequencerRequestX,
InitializeSequencerResponse,
InitializeSequencerResponseX,
}
import com.digitalasset.canton.domain.sequencing.sequencer.{LedgerIdentity, SequencerSnapshot}
import com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp
import com.digitalasset.canton.topology.store.StoredTopologyTransactions
import com.digitalasset.canton.topology.store.StoredTopologyTransactionsX.GenericStoredTopologyTransactionsX
import com.digitalasset.canton.topology.transaction.TopologyChangeOp
import com.digitalasset.canton.topology.{DomainId, Member}
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
object EnterpriseSequencerAdminCommands {
abstract class BaseSequencerInitializationCommand[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = v0.SequencerInitializationServiceGrpc.SequencerInitializationServiceStub
override def createService(
channel: ManagedChannel
): v0.SequencerInitializationServiceGrpc.SequencerInitializationServiceStub =
v0.SequencerInitializationServiceGrpc.stub(channel)
}
abstract class BaseSequencerAdministrationCommand[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc =
v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub
override def createService(
channel: ManagedChannel
): v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub =
v0.EnterpriseSequencerAdministrationServiceGrpc.stub(channel)
}
abstract class BaseSequencerTopologyBootstrapCommand[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = v0.TopologyBootstrapServiceGrpc.TopologyBootstrapServiceStub
override def createService(
channel: ManagedChannel
): v0.TopologyBootstrapServiceGrpc.TopologyBootstrapServiceStub =
v0.TopologyBootstrapServiceGrpc.stub(channel)
}
sealed trait Initialize[ProtoRequest]
extends BaseSequencerInitializationCommand[
ProtoRequest,
v0.InitResponse,
InitializeSequencerResponse,
] {
protected def domainId: DomainId
protected def topologySnapshot: StoredTopologyTransactions[TopologyChangeOp.Positive]
protected def domainParameters: StaticDomainParameters
protected def snapshotO: Option[SequencerSnapshot]
protected def serializer: InitializeSequencerRequest => ProtoRequest
override def createRequest(): Either[String, ProtoRequest] = {
val request = InitializeSequencerRequest(
domainId,
topologySnapshot,
domainParameters.toInternal,
snapshotO,
)
Right(serializer(request))
}
override def handleResponse(
response: v0.InitResponse
): Either[String, InitializeSequencerResponse] =
InitializeSequencerResponse
.fromProtoV0(response)
.leftMap(err => s"Failed to deserialize response: $err")
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
object Initialize {
final case class V2(
domainId: DomainId,
topologySnapshot: StoredTopologyTransactions[TopologyChangeOp.Positive],
domainParameters: StaticDomainParameters,
snapshotO: Option[SequencerSnapshot],
) extends Initialize[v2.InitRequest] {
override protected def serializer: InitializeSequencerRequest => v2.InitRequest = _.toProtoV2
override def submitRequest(
service: v0.SequencerInitializationServiceGrpc.SequencerInitializationServiceStub,
request: v2.InitRequest,
): Future[v0.InitResponse] =
service.initV2(request)
}
def apply(
domainId: DomainId,
topologySnapshot: StoredTopologyTransactions[TopologyChangeOp.Positive],
domainParameters: StaticDomainParameters,
snapshotO: Option[SequencerSnapshot] = None,
): Initialize[_] =
V2(domainId, topologySnapshot, domainParameters, snapshotO)
}
final case class InitializeX(
topologySnapshot: GenericStoredTopologyTransactionsX,
domainParameters: com.digitalasset.canton.protocol.StaticDomainParameters,
sequencerSnapshot: Option[SequencerSnapshot],
) extends GrpcAdminCommand[
v2.InitializeSequencerRequest,
v2.InitializeSequencerResponse,
InitializeSequencerResponseX,
] {
override type Svc = v2.SequencerInitializationServiceGrpc.SequencerInitializationServiceStub
override def createService(
channel: ManagedChannel
): SequencerInitializationServiceGrpc.SequencerInitializationServiceStub =
v2.SequencerInitializationServiceGrpc.stub(channel)
override def submitRequest(
service: SequencerInitializationServiceGrpc.SequencerInitializationServiceStub,
request: v2.InitializeSequencerRequest,
): Future[v2.InitializeSequencerResponse] =
service.initialize(request)
override def createRequest(): Either[String, v2.InitializeSequencerRequest] =
Right(
InitializeSequencerRequestX(
topologySnapshot,
domainParameters,
sequencerSnapshot,
).toProtoV2
)
override def handleResponse(
response: v2.InitializeSequencerResponse
): Either[String, InitializeSequencerResponseX] =
InitializeSequencerResponseX.fromProtoV2(response).leftMap(_.toString)
}
final case class Snapshot(timestamp: CantonTimestamp)
extends BaseSequencerAdministrationCommand[
v0.Snapshot.Request,
v0.Snapshot.Response,
SequencerSnapshot,
] {
override def createRequest(): Either[String, v0.Snapshot.Request] = {
Right(v0.Snapshot.Request(Some(timestamp.toProtoPrimitive)))
}
override def submitRequest(
service: v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub,
request: v0.Snapshot.Request,
): Future[v0.Snapshot.Response] = service.snapshot(request)
override def handleResponse(response: v0.Snapshot.Response): Either[String, SequencerSnapshot] =
response.value match {
case v0.Snapshot.Response.Value.Failure(v0.Snapshot.Failure(reason)) => Left(reason)
case v0.Snapshot.Response.Value.Success(v0.Snapshot.Success(Some(result))) =>
SequencerSnapshot.fromProtoV1(result).leftMap(_.toString)
case v0.Snapshot.Response.Value.VersionedSuccess(v0.Snapshot.VersionedSuccess(snapshot)) =>
SequencerSnapshot.fromByteString(snapshot).leftMap(_.toString)
case _ => Left("response is empty")
}
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class Prune(timestamp: CantonTimestamp)
extends BaseSequencerAdministrationCommand[v0.Pruning.Request, v0.Pruning.Response, String] {
override def createRequest(): Either[String, v0.Pruning.Request] =
Right(v0.Pruning.Request(timestamp.toProtoPrimitive.some))
override def submitRequest(
service: v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub,
request: v0.Pruning.Request,
): Future[v0.Pruning.Response] =
service.prune(request)
override def handleResponse(response: v0.Pruning.Response): Either[String, String] =
Either.cond(
response.details.nonEmpty,
response.details,
"Pruning response did not contain details",
)
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class LocatePruningTimestampCommand(index: PositiveInt)
extends BaseSequencerAdministrationCommand[
LocatePruningTimestamp.Request,
LocatePruningTimestamp.Response,
Option[CantonTimestamp],
] {
override def createRequest(): Either[String, LocatePruningTimestamp.Request] = Right(
LocatePruningTimestamp.Request(index.value)
)
override def submitRequest(
service: v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub,
request: LocatePruningTimestamp.Request,
): Future[LocatePruningTimestamp.Response] =
service.locatePruningTimestamp(request)
override def handleResponse(
response: LocatePruningTimestamp.Response
): Either[String, Option[CantonTimestamp]] =
response.timestamp.fold(Right(None): Either[String, Option[CantonTimestamp]])(
CantonTimestamp.fromProtoPrimitive(_).bimap(_.message, Some(_))
)
}
final case class DisableMember(member: Member)
extends BaseSequencerAdministrationCommand[v0.DisableMemberRequest, Empty, Unit] {
override def createRequest(): Either[String, v0.DisableMemberRequest] =
Right(v0.DisableMemberRequest(member.toProtoPrimitive))
override def submitRequest(
service: v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub,
request: v0.DisableMemberRequest,
): Future[Empty] = service.disableMember(request)
override def handleResponse(response: Empty): Either[String, Unit] = Right(())
}
final case class AuthorizeLedgerIdentity(ledgerIdentity: LedgerIdentity)
extends BaseSequencerAdministrationCommand[
v0.LedgerIdentity.AuthorizeRequest,
v0.LedgerIdentity.AuthorizeResponse,
Unit,
] {
override def createRequest(): Either[String, v0.LedgerIdentity.AuthorizeRequest] =
Right(v0.LedgerIdentity.AuthorizeRequest(Some(ledgerIdentity.toProtoV0)))
override def submitRequest(
service: v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub,
request: v0.LedgerIdentity.AuthorizeRequest,
): Future[v0.LedgerIdentity.AuthorizeResponse] = service.authorizeLedgerIdentity(request)
override def handleResponse(
response: v0.LedgerIdentity.AuthorizeResponse
): Either[String, Unit] = response.value match {
case v0.LedgerIdentity.AuthorizeResponse.Value.Failure(v0.LedgerIdentity.Failure(reason)) =>
Left(reason)
case v0.LedgerIdentity.AuthorizeResponse.Value.Success(v0.LedgerIdentity.Success()) =>
Right(())
case other => Left(s"Empty response: $other")
}
}
final case class BootstrapTopology(
topologySnapshot: StoredTopologyTransactions[TopologyChangeOp.Positive]
) extends BaseSequencerTopologyBootstrapCommand[v0.TopologyBootstrapRequest, Empty, Unit] {
override def createRequest(): Either[String, v0.TopologyBootstrapRequest] =
Right(v0.TopologyBootstrapRequest(Some(topologySnapshot.toProtoV0)))
override def submitRequest(
service: v0.TopologyBootstrapServiceGrpc.TopologyBootstrapServiceStub,
request: v0.TopologyBootstrapRequest,
): Future[Empty] =
service.bootstrap(request)
override def handleResponse(response: Empty): Either[String, Unit] = Right(())
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
}

View File

@ -0,0 +1,112 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import com.daml.ledger.api.v1.event.CreatedEvent
import com.daml.ledger.api.v1.value.{Record, RecordField, Value}
import com.daml.lf.data.Time
import com.daml.lf.transaction.TransactionCoder
import com.digitalasset.canton.admin.api.client.data.TemplateId
import com.digitalasset.canton.crypto.Salt
import com.digitalasset.canton.ledger.api.util.TimestampConversion
import com.digitalasset.canton.protocol.{DriverContractMetadata, LfContractId}
/** Wrapper class to make scalapb LedgerApi classes more convenient to access
*/
object LedgerApiTypeWrappers {
/*
Provide a few utilities methods on CreatedEvent.
Notes:
* We don't use an `implicit class` because it makes the use of pretty
instances difficult (e.g. for `ledger_api.acs.of_all`).
* Also, the name of some methods of `WrappedCreatedEvent`, such as `templateId`,
collides with one of the underlying event.
*/
final case class WrappedCreatedEvent(event: CreatedEvent) {
private def corrupt: String = s"corrupt event ${event.eventId} / ${event.contractId}"
def templateId: TemplateId = {
TemplateId.fromIdentifier(
event.templateId.getOrElse(
throw new IllegalArgumentException(
s"Template Id not specified for event ${event.eventId} / ${event.contractId}"
)
)
)
}
def packageId: String = {
event.templateId.map(_.packageId).getOrElse(corrupt)
}
private def flatten(prefix: Seq[String], field: RecordField): Seq[(String, Any)] = {
def extract(args: Value.Sum): Seq[(String, Any)] =
args match {
case x: Value.Sum.Record => x.value.fields.flatMap(flatten(prefix :+ field.label, _))
case x: Value.Sum.Variant => x.value.value.toList.map(_.sum).flatMap(extract)
case x => Seq(((prefix :+ field.label).mkString("."), x.value))
}
field.value.map(_.sum).toList.flatMap(extract)
}
def arguments: Map[String, Any] =
event.createArguments.toList.flatMap(_.fields).flatMap(flatten(Seq(), _)).toMap
def toContractData: ContractData = {
val templateId = TemplateId.fromIdentifier(
event.templateId.getOrElse(throw new IllegalArgumentException("Template Id not specified"))
)
val createArguments =
event.createArguments.getOrElse(
throw new IllegalArgumentException("Create Arguments not specified")
)
val lfContractId =
LfContractId
.fromString(event.contractId)
.getOrElse(
throw new IllegalArgumentException(s"Illegal Contract Id: ${event.contractId}")
)
val contractSaltO = for {
fatInstance <- TransactionCoder.decodeFatContractInstance(event.createdEventBlob).toOption
parsed = DriverContractMetadata.fromByteString(fatInstance.cantonData.toByteString)
} yield parsed.fold[Salt](
err =>
throw new IllegalArgumentException(
s"Could not deserialize driver contract metadata: ${err.message}"
),
_.salt,
)
val ledgerCreateTimeO =
event.createdAt.map(TimestampConversion.toLf(_, TimestampConversion.ConversionMode.Exact))
ContractData(
templateId = templateId,
createArguments = createArguments,
signatories = event.signatories.toSet,
observers = event.observers.toSet,
inheritedContractId = lfContractId,
contractSalt = contractSaltO,
ledgerCreateTime = ledgerCreateTimeO,
)
}
}
/** Holder of "core" contract defining fields (particularly those relevant for importing contracts) */
final case class ContractData(
templateId: TemplateId,
createArguments: Record,
// track signatories and observers for use as auth validation by daml engine
signatories: Set[String],
observers: Set[String],
inheritedContractId: LfContractId,
contractSalt: Option[Salt],
ledgerCreateTime: Option[Time.Timestamp],
)
}

View File

@ -0,0 +1,849 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import com.daml.ledger.api.v1.command_completion_service.Checkpoint
import com.daml.ledger.api.v1.commands.{Command, DisclosedContract}
import com.daml.ledger.api.v1.event_query_service.GetEventsByContractIdRequest
import com.daml.ledger.api.v1.transaction_filter.{Filters, InclusiveFilters, TemplateFilter}
import com.daml.ledger.api.v2.command_completion_service.CommandCompletionServiceGrpc.CommandCompletionServiceStub
import com.daml.ledger.api.v2.command_completion_service.{
CommandCompletionServiceGrpc,
CompletionStreamRequest,
CompletionStreamResponse,
}
import com.daml.ledger.api.v2.command_service.CommandServiceGrpc.CommandServiceStub
import com.daml.ledger.api.v2.command_service.{
CommandServiceGrpc,
SubmitAndWaitForTransactionResponse,
SubmitAndWaitForTransactionTreeResponse,
SubmitAndWaitRequest,
}
import com.daml.ledger.api.v2.command_submission_service.CommandSubmissionServiceGrpc.CommandSubmissionServiceStub
import com.daml.ledger.api.v2.command_submission_service.{
CommandSubmissionServiceGrpc,
SubmitReassignmentRequest,
SubmitReassignmentResponse,
SubmitRequest,
SubmitResponse,
}
import com.daml.ledger.api.v2.commands.Commands
import com.daml.ledger.api.v2.completion.Completion
import com.daml.ledger.api.v2.event_query_service.EventQueryServiceGrpc.EventQueryServiceStub
import com.daml.ledger.api.v2.event_query_service.{
EventQueryServiceGrpc,
GetEventsByContractIdResponse,
}
import com.daml.ledger.api.v2.participant_offset.ParticipantOffset
import com.daml.ledger.api.v2.reassignment.{AssignedEvent, Reassignment, UnassignedEvent}
import com.daml.ledger.api.v2.reassignment_command.{
AssignCommand,
ReassignmentCommand,
UnassignCommand,
}
import com.daml.ledger.api.v2.state_service.StateServiceGrpc.StateServiceStub
import com.daml.ledger.api.v2.state_service.{
GetActiveContractsRequest,
GetActiveContractsResponse,
GetConnectedDomainsRequest,
GetConnectedDomainsResponse,
GetLedgerEndRequest,
GetLedgerEndResponse,
StateServiceGrpc,
}
import com.daml.ledger.api.v2.testing.time_service.TimeServiceGrpc.TimeServiceStub
import com.daml.ledger.api.v2.testing.time_service.{
GetTimeRequest,
GetTimeResponse,
SetTimeRequest,
TimeServiceGrpc,
}
import com.daml.ledger.api.v2.transaction.{Transaction, TransactionTree}
import com.daml.ledger.api.v2.transaction_filter.TransactionFilter
import com.daml.ledger.api.v2.update_service.UpdateServiceGrpc.UpdateServiceStub
import com.daml.ledger.api.v2.update_service.{
GetTransactionByIdRequest,
GetTransactionTreeResponse,
GetUpdateTreesResponse,
GetUpdatesRequest,
GetUpdatesResponse,
UpdateServiceGrpc,
}
import com.digitalasset.canton.LfPartyId
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultUnboundedTimeout,
ServerEnforcedTimeout,
TimeoutType,
}
import com.digitalasset.canton.admin.api.client.data.TemplateId
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.ledger.api.DeduplicationPeriod
import com.digitalasset.canton.logging.ErrorLoggingContext
import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting}
import com.digitalasset.canton.networking.grpc.ForwardingStreamObserver
import com.digitalasset.canton.protocol.LfContractId
import com.digitalasset.canton.serialization.ProtoConverter
import com.digitalasset.canton.topology.DomainId
import com.google.protobuf.empty.Empty
import io.grpc.*
import io.grpc.stub.StreamObserver
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ScheduledExecutorService
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future}
// TODO(#15280) delete LedgerApiCommands, and rename this to LedgerApiCommands
object LedgerApiV2Commands {
object UpdateService {
sealed trait UpdateTreeWrapper
sealed trait UpdateWrapper
final case class TransactionTreeWrapper(transactionTree: TransactionTree)
extends UpdateTreeWrapper
final case class TransactionWrapper(transaction: Transaction) extends UpdateWrapper
sealed trait ReassignmentWrapper extends UpdateTreeWrapper with UpdateWrapper {
def reassignment: Reassignment
}
object ReassignmentWrapper {
def apply(reassignment: Reassignment): ReassignmentWrapper = {
val event = reassignment.event
event.assignedEvent
.map[ReassignmentWrapper](AssignedWrapper(reassignment, _))
.orElse(
event.unassignedEvent.map[ReassignmentWrapper](UnassignedWrapper(reassignment, _))
)
.getOrElse(
throw new IllegalStateException(
s"Invalid reassignment event (only supported UnassignedEvent and AssignedEvent): ${reassignment.event}"
)
)
}
}
final case class AssignedWrapper(reassignment: Reassignment, assignedEvent: AssignedEvent)
extends ReassignmentWrapper
final case class UnassignedWrapper(reassignment: Reassignment, unassignedEvent: UnassignedEvent)
extends ReassignmentWrapper
trait BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = UpdateServiceStub
override def createService(channel: ManagedChannel): UpdateServiceStub =
UpdateServiceGrpc.stub(channel)
}
trait SubscribeBase[Resp, Res]
extends BaseCommand[GetUpdatesRequest, AutoCloseable, AutoCloseable] {
// The subscription should never be cut short because of a gRPC timeout
override def timeoutType: TimeoutType = ServerEnforcedTimeout
def observer: StreamObserver[Res]
def beginExclusive: ParticipantOffset
def endInclusive: Option[ParticipantOffset]
def filter: TransactionFilter
def verbose: Boolean
def doRequest(
service: UpdateServiceStub,
request: GetUpdatesRequest,
rawObserver: StreamObserver[Resp],
): Unit
def extractResults(response: Resp): IterableOnce[Res]
implicit def loggingContext: ErrorLoggingContext
override def createRequest(): Either[String, GetUpdatesRequest] = Right {
GetUpdatesRequest(
beginExclusive = Some(beginExclusive),
endInclusive = endInclusive,
verbose = verbose,
filter = Some(filter),
)
}
override def submitRequest(
service: UpdateServiceStub,
request: GetUpdatesRequest,
): Future[AutoCloseable] = {
val rawObserver = new ForwardingStreamObserver[Resp, Res](observer, extractResults)
val context = Context.current().withCancellation()
context.run(() => doRequest(service, request, rawObserver))
Future.successful(context)
}
override def handleResponse(response: AutoCloseable): Either[String, AutoCloseable] = Right(
response
)
}
final case class SubscribeTrees(
override val observer: StreamObserver[UpdateTreeWrapper],
override val beginExclusive: ParticipantOffset,
override val endInclusive: Option[ParticipantOffset],
override val filter: TransactionFilter,
override val verbose: Boolean,
)(override implicit val loggingContext: ErrorLoggingContext)
extends SubscribeBase[GetUpdateTreesResponse, UpdateTreeWrapper] {
override def doRequest(
service: UpdateServiceStub,
request: GetUpdatesRequest,
rawObserver: StreamObserver[GetUpdateTreesResponse],
): Unit =
service.getUpdateTrees(request, rawObserver)
override def extractResults(
response: GetUpdateTreesResponse
): IterableOnce[UpdateTreeWrapper] =
response.update.transactionTree
.map[UpdateTreeWrapper](TransactionTreeWrapper)
.orElse(response.update.reassignment.map(ReassignmentWrapper(_)))
}
final case class SubscribeFlat(
override val observer: StreamObserver[UpdateWrapper],
override val beginExclusive: ParticipantOffset,
override val endInclusive: Option[ParticipantOffset],
override val filter: TransactionFilter,
override val verbose: Boolean,
)(override implicit val loggingContext: ErrorLoggingContext)
extends SubscribeBase[GetUpdatesResponse, UpdateWrapper] {
override def doRequest(
service: UpdateServiceStub,
request: GetUpdatesRequest,
rawObserver: StreamObserver[GetUpdatesResponse],
): Unit =
service.getUpdates(request, rawObserver)
override def extractResults(response: GetUpdatesResponse): IterableOnce[UpdateWrapper] =
response.update.transaction
.map[UpdateWrapper](TransactionWrapper)
.orElse(response.update.reassignment.map(ReassignmentWrapper(_)))
}
final case class GetTransactionById(parties: Set[LfPartyId], id: String)(implicit
ec: ExecutionContext
) extends BaseCommand[GetTransactionByIdRequest, GetTransactionTreeResponse, Option[
TransactionTree
]]
with PrettyPrinting {
override def createRequest(): Either[String, GetTransactionByIdRequest] = Right {
GetTransactionByIdRequest(
updateId = id,
requestingParties = parties.toSeq,
)
}
override def submitRequest(
service: UpdateServiceStub,
request: GetTransactionByIdRequest,
): Future[GetTransactionTreeResponse] = {
// The Ledger API will throw an error if it can't find a transaction by ID.
// However, as Canton is distributed, a transaction ID might show up later, so we don't treat this as
// an error and change it to a None
service.getTransactionTreeById(request).recover {
case e: StatusRuntimeException if e.getStatus.getCode == Status.Code.NOT_FOUND =>
GetTransactionTreeResponse(None)
}
}
override def handleResponse(
response: GetTransactionTreeResponse
): Either[String, Option[TransactionTree]] =
Right(response.transaction)
override def pretty: Pretty[GetTransactionById] =
prettyOfClass(
param("id", _.id.unquoted),
param("parties", _.parties),
)
}
}
private[commands] trait SubmitCommand extends PrettyPrinting {
def actAs: Seq[LfPartyId]
def readAs: Seq[LfPartyId]
def commands: Seq[Command]
def workflowId: String
def commandId: String
def deduplicationPeriod: Option[DeduplicationPeriod]
def submissionId: String
def minLedgerTimeAbs: Option[Instant]
def disclosedContracts: Seq[DisclosedContract]
def domainId: DomainId
def applicationId: String
protected def mkCommand: Commands = Commands(
workflowId = workflowId,
applicationId = applicationId,
commandId = if (commandId.isEmpty) UUID.randomUUID().toString else commandId,
actAs = actAs,
readAs = readAs,
commands = commands,
deduplicationPeriod = deduplicationPeriod.fold(
Commands.DeduplicationPeriod.Empty: Commands.DeduplicationPeriod
) {
case DeduplicationPeriod.DeduplicationDuration(duration) =>
Commands.DeduplicationPeriod.DeduplicationDuration(
ProtoConverter.DurationConverter.toProtoPrimitive(duration)
)
case DeduplicationPeriod.DeduplicationOffset(offset) =>
Commands.DeduplicationPeriod.DeduplicationOffset(
offset.toHexString
)
},
minLedgerTimeAbs = minLedgerTimeAbs.map(ProtoConverter.InstantConverter.toProtoPrimitive),
submissionId = submissionId,
disclosedContracts = disclosedContracts,
domainId = domainId.toProtoPrimitive,
)
override def pretty: Pretty[this.type] =
prettyOfClass(
param("actAs", _.actAs),
param("readAs", _.readAs),
param("commandId", _.commandId.singleQuoted),
param("workflowId", _.workflowId.singleQuoted),
param("submissionId", _.submissionId.singleQuoted),
param("deduplicationPeriod", _.deduplicationPeriod),
paramIfDefined("minLedgerTimeAbs", _.minLedgerTimeAbs),
paramWithoutValue("commands"),
)
}
object CommandSubmissionService {
trait BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = CommandSubmissionServiceStub
override def createService(channel: ManagedChannel): CommandSubmissionServiceStub =
CommandSubmissionServiceGrpc.stub(channel)
}
final case class Submit(
override val actAs: Seq[LfPartyId],
override val readAs: Seq[LfPartyId],
override val commands: Seq[Command],
override val workflowId: String,
override val commandId: String,
override val deduplicationPeriod: Option[DeduplicationPeriod],
override val submissionId: String,
override val minLedgerTimeAbs: Option[Instant],
override val disclosedContracts: Seq[DisclosedContract],
override val domainId: DomainId,
override val applicationId: String,
) extends SubmitCommand
with BaseCommand[SubmitRequest, SubmitResponse, Unit] {
override def createRequest(): Either[String, SubmitRequest] = Right(
SubmitRequest(commands = Some(mkCommand))
)
override def submitRequest(
service: CommandSubmissionServiceStub,
request: SubmitRequest,
): Future[SubmitResponse] = {
service.submit(request)
}
override def handleResponse(response: SubmitResponse): Either[String, Unit] = Right(())
}
final case class SubmitAssignCommand(
workflowId: String,
applicationId: String,
commandId: String,
submitter: LfPartyId,
submissionId: String,
unassignId: String,
source: DomainId,
target: DomainId,
) extends BaseCommand[SubmitReassignmentRequest, SubmitReassignmentResponse, Unit] {
override def createRequest(): Either[String, SubmitReassignmentRequest] = Right(
SubmitReassignmentRequest(
Some(
ReassignmentCommand(
workflowId = workflowId,
applicationId = applicationId,
commandId = commandId,
submitter = submitter.toString,
command = ReassignmentCommand.Command.AssignCommand(
AssignCommand(
unassignId = unassignId,
source = source.toProtoPrimitive,
target = target.toProtoPrimitive,
)
),
submissionId = submissionId,
)
)
)
)
override def submitRequest(
service: CommandSubmissionServiceStub,
request: SubmitReassignmentRequest,
): Future[SubmitReassignmentResponse] = {
service.submitReassignment(request)
}
override def handleResponse(response: SubmitReassignmentResponse): Either[String, Unit] =
Right(())
}
final case class SubmitUnassignCommand(
workflowId: String,
applicationId: String,
commandId: String,
submitter: LfPartyId,
submissionId: String,
contractId: LfContractId,
source: DomainId,
target: DomainId,
) extends BaseCommand[SubmitReassignmentRequest, SubmitReassignmentResponse, Unit] {
override def createRequest(): Either[String, SubmitReassignmentRequest] = Right(
SubmitReassignmentRequest(
Some(
ReassignmentCommand(
workflowId = workflowId,
applicationId = applicationId,
commandId = commandId,
submitter = submitter.toString,
command = ReassignmentCommand.Command.UnassignCommand(
UnassignCommand(
contractId = contractId.coid.toString,
source = source.toProtoPrimitive,
target = target.toProtoPrimitive,
)
),
submissionId = submissionId,
)
)
)
)
override def submitRequest(
service: CommandSubmissionServiceStub,
request: SubmitReassignmentRequest,
): Future[SubmitReassignmentResponse] = {
service.submitReassignment(request)
}
override def handleResponse(response: SubmitReassignmentResponse): Either[String, Unit] =
Right(())
}
}
object CommandService {
trait BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = CommandServiceStub
override def createService(channel: ManagedChannel): CommandServiceStub =
CommandServiceGrpc.stub(channel)
}
final case class SubmitAndWaitTransactionTree(
override val actAs: Seq[LfPartyId],
override val readAs: Seq[LfPartyId],
override val commands: Seq[Command],
override val workflowId: String,
override val commandId: String,
override val deduplicationPeriod: Option[DeduplicationPeriod],
override val submissionId: String,
override val minLedgerTimeAbs: Option[Instant],
override val disclosedContracts: Seq[DisclosedContract],
override val domainId: DomainId,
override val applicationId: String,
) extends SubmitCommand
with BaseCommand[
SubmitAndWaitRequest,
SubmitAndWaitForTransactionTreeResponse,
TransactionTree,
] {
override def createRequest(): Either[String, SubmitAndWaitRequest] =
Right(SubmitAndWaitRequest(commands = Some(mkCommand)))
override def submitRequest(
service: CommandServiceStub,
request: SubmitAndWaitRequest,
): Future[SubmitAndWaitForTransactionTreeResponse] =
service.submitAndWaitForTransactionTree(request)
override def handleResponse(
response: SubmitAndWaitForTransactionTreeResponse
): Either[String, TransactionTree] =
response.transaction.toRight("Received response without any transaction tree")
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class SubmitAndWaitTransaction(
override val actAs: Seq[LfPartyId],
override val readAs: Seq[LfPartyId],
override val commands: Seq[Command],
override val workflowId: String,
override val commandId: String,
override val deduplicationPeriod: Option[DeduplicationPeriod],
override val submissionId: String,
override val minLedgerTimeAbs: Option[Instant],
override val disclosedContracts: Seq[DisclosedContract],
override val domainId: DomainId,
override val applicationId: String,
) extends SubmitCommand
with BaseCommand[SubmitAndWaitRequest, SubmitAndWaitForTransactionResponse, Transaction] {
override def createRequest(): Either[String, SubmitAndWaitRequest] =
Right(SubmitAndWaitRequest(commands = Some(mkCommand)))
override def submitRequest(
service: CommandServiceStub,
request: SubmitAndWaitRequest,
): Future[SubmitAndWaitForTransactionResponse] =
service.submitAndWaitForTransaction(request)
override def handleResponse(
response: SubmitAndWaitForTransactionResponse
): Either[String, Transaction] =
response.transaction.toRight("Received response without any transaction")
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
}
object StateService {
abstract class BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = StateServiceStub
override def createService(channel: ManagedChannel): StateServiceStub =
StateServiceGrpc.stub(channel)
}
final case class LedgerEnd()
extends BaseCommand[GetLedgerEndRequest, GetLedgerEndResponse, ParticipantOffset] {
override def createRequest(): Either[String, GetLedgerEndRequest] =
Right(GetLedgerEndRequest())
override def submitRequest(
service: StateServiceStub,
request: GetLedgerEndRequest,
): Future[GetLedgerEndResponse] =
service.getLedgerEnd(request)
override def handleResponse(
response: GetLedgerEndResponse
): Either[String, ParticipantOffset] =
response.offset.toRight("Empty LedgerEndResponse received without offset")
}
final case class GetConnectedDomains(partyId: LfPartyId)
extends BaseCommand[
GetConnectedDomainsRequest,
GetConnectedDomainsResponse,
GetConnectedDomainsResponse,
] {
override def createRequest(): Either[String, GetConnectedDomainsRequest] =
Right(GetConnectedDomainsRequest(partyId.toString))
override def submitRequest(
service: StateServiceStub,
request: GetConnectedDomainsRequest,
): Future[GetConnectedDomainsResponse] =
service.getConnectedDomains(request)
override def handleResponse(
response: GetConnectedDomainsResponse
): Either[String, GetConnectedDomainsResponse] =
Right(response)
}
final case class GetActiveContracts(
parties: Set[LfPartyId],
limit: PositiveInt,
templateFilter: Seq[TemplateId] = Seq.empty,
activeAtOffset: String = "",
verbose: Boolean = true,
timeout: FiniteDuration,
includeCreatedEventBlob: Boolean = false,
)(scheduler: ScheduledExecutorService)
extends BaseCommand[GetActiveContractsRequest, Seq[GetActiveContractsResponse], Seq[
GetActiveContractsResponse
]] {
override def createRequest(): Either[String, GetActiveContractsRequest] = {
val filter =
if (templateFilter.nonEmpty) {
Filters(
Some(
InclusiveFilters(templateFilters =
templateFilter.map(tId =>
TemplateFilter(Some(tId.toIdentifier), includeCreatedEventBlob)
)
)
)
)
} else Filters.defaultInstance
Right(
GetActiveContractsRequest(
filter = Some(TransactionFilter(parties.map((_, filter)).toMap)),
verbose = verbose,
activeAtOffset = activeAtOffset,
)
)
}
override def submitRequest(
service: StateServiceStub,
request: GetActiveContractsRequest,
): Future[Seq[GetActiveContractsResponse]] = {
GrpcAdminCommand.streamedResponse[
GetActiveContractsRequest,
GetActiveContractsResponse,
GetActiveContractsResponse,
](
service.getActiveContracts,
List(_),
request,
limit.value,
timeout,
scheduler,
)
}
override def handleResponse(
response: Seq[GetActiveContractsResponse]
): Either[String, Seq[GetActiveContractsResponse]] = {
Right(response)
}
// fetching ACS might take long if we fetch a lot of data
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
}
final case class CompletionWrapper(
completion: Completion,
checkpoint: Checkpoint,
domainId: DomainId,
)
object CommandCompletionService {
abstract class BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = CommandCompletionServiceStub
override def createService(channel: ManagedChannel): CommandCompletionServiceStub =
CommandCompletionServiceGrpc.stub(channel)
}
final case class CompletionRequest(
partyId: LfPartyId,
beginOffset: ParticipantOffset,
expectedCompletions: Int,
timeout: java.time.Duration,
applicationId: String,
)(filter: CompletionWrapper => Boolean, scheduler: ScheduledExecutorService)
extends BaseCommand[
CompletionStreamRequest,
Seq[CompletionWrapper],
Seq[CompletionWrapper],
] {
override def createRequest(): Either[String, CompletionStreamRequest] =
Right(
CompletionStreamRequest(
applicationId = applicationId,
parties = Seq(partyId),
beginExclusive = Some(beginOffset),
)
)
override def submitRequest(
service: CommandCompletionServiceStub,
request: CompletionStreamRequest,
): Future[Seq[CompletionWrapper]] = {
import scala.jdk.DurationConverters.*
GrpcAdminCommand
.streamedResponse[CompletionStreamRequest, CompletionStreamResponse, CompletionWrapper](
service.completionStream,
response =>
List(
CompletionWrapper(
completion = response.completion.getOrElse(
throw new IllegalStateException("Completion should be present.")
),
checkpoint = response.checkpoint.getOrElse(
throw new IllegalStateException("Checkpoint should be present.")
),
domainId = DomainId.tryFromString(response.domainId),
)
).filter(filter),
request,
expectedCompletions,
timeout.toScala,
scheduler,
)
}
override def handleResponse(
response: Seq[CompletionWrapper]
): Either[String, Seq[CompletionWrapper]] =
Right(response)
override def timeoutType: TimeoutType = ServerEnforcedTimeout
}
final case class Subscribe(
observer: StreamObserver[CompletionWrapper],
parties: Seq[String],
offset: Option[ParticipantOffset],
applicationId: String,
)(implicit loggingContext: ErrorLoggingContext)
extends BaseCommand[CompletionStreamRequest, AutoCloseable, AutoCloseable] {
// The subscription should never be cut short because of a gRPC timeout
override def timeoutType: TimeoutType = ServerEnforcedTimeout
override def createRequest(): Either[String, CompletionStreamRequest] = Right {
CompletionStreamRequest(
applicationId = applicationId,
parties = parties,
beginExclusive = offset,
)
}
override def submitRequest(
service: CommandCompletionServiceStub,
request: CompletionStreamRequest,
): Future[AutoCloseable] = {
val rawObserver = new ForwardingStreamObserver[CompletionStreamResponse, CompletionWrapper](
observer,
response =>
List(
CompletionWrapper(
completion = response.completion.getOrElse(
throw new IllegalStateException("Completion should be present.")
),
checkpoint = response.checkpoint.getOrElse(
throw new IllegalStateException("Checkpoint should be present.")
),
domainId = DomainId.tryFromString(response.domainId),
)
),
)
val context = Context.current().withCancellation()
context.run(() => service.completionStream(request, rawObserver))
Future.successful(context)
}
override def handleResponse(response: AutoCloseable): Either[String, AutoCloseable] = Right(
response
)
}
}
object Time {
abstract class BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = TimeServiceStub
override def createService(channel: ManagedChannel): TimeServiceStub =
TimeServiceGrpc.stub(channel)
}
final object Get
extends BaseCommand[
GetTimeRequest,
GetTimeResponse,
CantonTimestamp,
] {
override def submitRequest(
service: TimeServiceStub,
request: GetTimeRequest,
): Future[GetTimeResponse] = {
service.getTime(request)
}
/** Create the request from configured options
*/
override def createRequest(): Either[String, GetTimeRequest] = Right(GetTimeRequest())
/** Handle the response the service has provided
*/
override def handleResponse(
response: GetTimeResponse
): Either[String, CantonTimestamp] =
for {
prototTimestamp <- response.currentTime.map(Right(_)).getOrElse(Left("currentTime empty"))
result <- CantonTimestamp.fromProtoPrimitive(prototTimestamp).left.map(_.message)
} yield result
}
final case class Set(currentTime: CantonTimestamp, newTime: CantonTimestamp)
extends BaseCommand[
SetTimeRequest,
Empty,
Unit,
] {
override def submitRequest(service: TimeServiceStub, request: SetTimeRequest): Future[Empty] =
service.setTime(request)
override def createRequest(): Either[String, SetTimeRequest] =
Right(
SetTimeRequest(
currentTime = Some(currentTime.toProtoPrimitive),
newTime = Some(newTime.toProtoPrimitive),
)
)
/** Handle the response the service has provided
*/
override def handleResponse(response: Empty): Either[String, Unit] = Right(())
}
}
object QueryService {
abstract class BaseCommand[Req, Res] extends GrpcAdminCommand[Req, Res, Res] {
override type Svc = EventQueryServiceStub
override def createService(channel: ManagedChannel): EventQueryServiceStub =
EventQueryServiceGrpc.stub(channel)
override def handleResponse(response: Res): Either[String, Res] = Right(response)
}
final case class GetEventsByContractId(
contractId: String,
requestingParties: Seq[String],
) extends BaseCommand[
GetEventsByContractIdRequest,
GetEventsByContractIdResponse,
] {
override def createRequest(): Either[String, GetEventsByContractIdRequest] = Right(
GetEventsByContractIdRequest(
contractId = contractId,
requestingParties = requestingParties,
)
)
override def submitRequest(
service: EventQueryServiceStub,
request: GetEventsByContractIdRequest,
): Future[GetEventsByContractIdResponse] = service.getEventsByContractId(request)
}
}
}

View File

@ -0,0 +1,158 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import com.digitalasset.canton.admin.api.client.data.PruningSchedule
import com.digitalasset.canton.config.PositiveDurationSeconds
import com.digitalasset.canton.pruning.admin.v0
import com.digitalasset.canton.pruning.admin.v0.{PruningSchedule as PruningScheduleP, *}
import io.grpc.ManagedChannel
import io.grpc.stub.AbstractStub
import scala.concurrent.Future
/** Exposes shared grpc client pruning scheduler commands reusable by participant/mediator/sequencer
* admin api.
* Having to type-parameterize as grpc does not support inheritance and passing in the grpc stub methods in one by one
*/
class PruningSchedulerCommands[Stub <: AbstractStub[Stub]](
createServiceStub: ManagedChannel => Stub,
submitSetSchedule: (Stub, SetSchedule.Request) => Future[SetSchedule.Response],
submitClearSchedule: (Stub, ClearSchedule.Request) => Future[ClearSchedule.Response],
submitSetCron: (Stub, SetCron.Request) => Future[SetCron.Response],
submitSetMaxDuration: (Stub, v0.SetMaxDuration.Request) => Future[SetMaxDuration.Response],
submitSetRetention: (Stub, SetRetention.Request) => Future[SetRetention.Response],
submitGetSchedule: (Stub, GetSchedule.Request) => Future[GetSchedule.Response],
) {
abstract class BaseCommand[Req, Res, Ret] extends GrpcAdminCommand[Req, Res, Ret] {
override type Svc = Stub
override def createService(channel: ManagedChannel): Svc = createServiceStub(channel)
}
// case classes not final as the scala compiler can't check outer Svc type reference
case class SetScheduleCommand(
cron: String,
maxDuration: PositiveDurationSeconds,
retention: PositiveDurationSeconds,
) extends BaseCommand[SetSchedule.Request, SetSchedule.Response, Unit] {
override def createRequest(): Right[String, SetSchedule.Request] =
Right(
SetSchedule.Request(
Some(
PruningScheduleP(
cron,
Some(maxDuration.toProtoPrimitive),
Some(retention.toProtoPrimitive),
)
)
)
)
override def submitRequest(
service: Svc,
request: SetSchedule.Request,
): Future[SetSchedule.Response] = submitSetSchedule(service, request)
override def handleResponse(response: SetSchedule.Response): Either[String, Unit] =
response match {
case SetSchedule.Response() => Right(())
}
}
case class ClearScheduleCommand()
extends BaseCommand[ClearSchedule.Request, ClearSchedule.Response, Unit] {
override def createRequest(): Right[String, ClearSchedule.Request] =
Right(ClearSchedule.Request())
override def submitRequest(
service: Svc,
request: ClearSchedule.Request,
): Future[ClearSchedule.Response] =
submitClearSchedule(service, request)
override def handleResponse(response: ClearSchedule.Response): Either[String, Unit] =
response match {
case ClearSchedule.Response() => Right(())
}
}
case class SetCronCommand(cron: String)
extends BaseCommand[SetCron.Request, SetCron.Response, Unit] {
override def createRequest(): Right[String, SetCron.Request] =
Right(SetCron.Request(cron))
override def submitRequest(
service: Svc,
request: SetCron.Request,
): Future[SetCron.Response] =
submitSetCron(service, request)
override def handleResponse(response: SetCron.Response): Either[String, Unit] =
response match {
case SetCron.Response() => Right(())
}
}
case class SetMaxDurationCommand(maxDuration: PositiveDurationSeconds)
extends BaseCommand[SetMaxDuration.Request, SetMaxDuration.Response, Unit] {
override def createRequest(): Right[String, SetMaxDuration.Request] =
Right(
SetMaxDuration.Request(Some(maxDuration.toProtoPrimitive))
)
override def submitRequest(
service: Svc,
request: SetMaxDuration.Request,
): Future[SetMaxDuration.Response] =
submitSetMaxDuration(service, request)
override def handleResponse(response: SetMaxDuration.Response): Either[String, Unit] =
response match {
case SetMaxDuration.Response() => Right(())
}
}
case class SetRetentionCommand(retention: PositiveDurationSeconds)
extends BaseCommand[SetRetention.Request, SetRetention.Response, Unit] {
override def createRequest(): Right[String, SetRetention.Request] =
Right(SetRetention.Request(Some(retention.toProtoPrimitive)))
override def submitRequest(
service: Svc,
request: SetRetention.Request,
): Future[SetRetention.Response] =
submitSetRetention(service, request)
override def handleResponse(response: SetRetention.Response): Either[String, Unit] =
response match {
case SetRetention.Response() => Right(())
}
}
case class GetScheduleCommand()
extends BaseCommand[
GetSchedule.Request,
GetSchedule.Response,
Option[PruningSchedule],
] {
override def createRequest(): Right[String, GetSchedule.Request] =
Right(GetSchedule.Request())
override def submitRequest(
service: Svc,
request: GetSchedule.Request,
): Future[GetSchedule.Response] =
submitGetSchedule(service, request)
override def handleResponse(
response: GetSchedule.Response
): Either[
String,
Option[PruningSchedule],
] = response.schedule.fold(
Right(None): Either[String, Option[PruningSchedule]]
)(PruningSchedule.fromProtoV0(_).bimap(_.message, Some(_)))
}
}

View File

@ -0,0 +1,68 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.digitalasset.canton.domain.admin.v0.SequencerAdministrationServiceGrpc.SequencerAdministrationServiceStub
import com.digitalasset.canton.domain.admin.v0 as adminproto
import com.digitalasset.canton.domain.sequencing.sequencer.SequencerPruningStatus
import com.digitalasset.canton.domain.sequencing.sequencer.traffic.SequencerTrafficStatus
import com.digitalasset.canton.topology.Member
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
object SequencerAdminCommands {
abstract class BaseSequencerAdministrationCommands[Req, Rep, Res]
extends GrpcAdminCommand[Req, Rep, Res] {
override type Svc = SequencerAdministrationServiceStub
override def createService(channel: ManagedChannel): SequencerAdministrationServiceStub =
adminproto.SequencerAdministrationServiceGrpc.stub(channel)
}
final case object GetPruningStatus
extends BaseSequencerAdministrationCommands[
Empty,
adminproto.SequencerPruningStatus,
SequencerPruningStatus,
] {
override def createRequest(): Either[String, Empty] = Right(Empty())
override def submitRequest(
service: SequencerAdministrationServiceStub,
request: Empty,
): Future[adminproto.SequencerPruningStatus] =
service.pruningStatus(request)
override def handleResponse(
response: adminproto.SequencerPruningStatus
): Either[String, SequencerPruningStatus] =
SequencerPruningStatus.fromProtoV0(response).leftMap(_.toString)
}
final case class GetTrafficControlState(members: Seq[Member])
extends BaseSequencerAdministrationCommands[
adminproto.TrafficControlStateRequest,
adminproto.TrafficControlStateResponse,
SequencerTrafficStatus,
] {
override def createRequest(): Either[String, adminproto.TrafficControlStateRequest] = Right(
adminproto.TrafficControlStateRequest(members.map(_.toProtoPrimitive))
)
override def submitRequest(
service: SequencerAdministrationServiceStub,
request: adminproto.TrafficControlStateRequest,
): Future[adminproto.TrafficControlStateResponse] =
service.trafficControlState(request)
override def handleResponse(
response: adminproto.TrafficControlStateResponse
): Either[String, SequencerTrafficStatus] =
response.trafficStates
.traverse(com.digitalasset.canton.traffic.MemberTrafficStatus.fromProtoV0)
.leftMap(_.toString)
.map(SequencerTrafficStatus)
}
}

View File

@ -0,0 +1,89 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import com.digitalasset.canton.ProtoDeserializationError
import com.digitalasset.canton.health.admin.v0.{HealthDumpChunk, HealthDumpRequest}
import com.digitalasset.canton.health.admin.{data, v0}
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.google.protobuf.empty.Empty
import io.grpc.Context.CancellableContext
import io.grpc.stub.StreamObserver
import io.grpc.{Context, ManagedChannel}
import scala.concurrent.Future
object StatusAdminCommands {
abstract class GetStatusBase[Result] extends GrpcAdminCommand[Empty, v0.NodeStatus, Result] {
override type Svc = v0.StatusServiceGrpc.StatusServiceStub
override def createService(channel: ManagedChannel): v0.StatusServiceGrpc.StatusServiceStub =
v0.StatusServiceGrpc.stub(channel)
override def createRequest(): Either[String, Empty] = Right(Empty())
override def submitRequest(
service: v0.StatusServiceGrpc.StatusServiceStub,
request: Empty,
): Future[v0.NodeStatus] =
service.status(request)
}
class GetStatus[S <: data.NodeStatus.Status](
deserialize: v0.NodeStatus.Status => ParsingResult[S]
) extends GetStatusBase[data.NodeStatus[S]] {
override def handleResponse(response: v0.NodeStatus): Either[String, data.NodeStatus[S]] =
((response.response match {
case v0.NodeStatus.Response.NotInitialized(notInitialized) =>
Right(data.NodeStatus.NotInitialized(notInitialized.active))
case v0.NodeStatus.Response.Success(status) =>
deserialize(status).map(data.NodeStatus.Success(_))
case v0.NodeStatus.Response.Empty => Left(ProtoDeserializationError.FieldNotSet("response"))
}): ParsingResult[data.NodeStatus[S]]).leftMap(_.toString)
}
class GetHealthDump(
observer: StreamObserver[HealthDumpChunk],
chunkSize: Option[Int],
) extends GrpcAdminCommand[HealthDumpRequest, CancellableContext, CancellableContext] {
override type Svc = v0.StatusServiceGrpc.StatusServiceStub
override def createService(channel: ManagedChannel): v0.StatusServiceGrpc.StatusServiceStub =
v0.StatusServiceGrpc.stub(channel)
override def submitRequest(
service: v0.StatusServiceGrpc.StatusServiceStub,
request: HealthDumpRequest,
): Future[CancellableContext] = {
val context = Context.current().withCancellation()
context.run(() => service.healthDump(request, observer))
Future.successful(context)
}
override def createRequest(): Either[String, HealthDumpRequest] = Right(
HealthDumpRequest(chunkSize)
)
override def handleResponse(response: CancellableContext): Either[String, CancellableContext] =
Right(response)
override def timeoutType: GrpcAdminCommand.TimeoutType =
GrpcAdminCommand.DefaultUnboundedTimeout
}
object IsRunning
extends StatusAdminCommands.FromStatus({
case v0.NodeStatus.Response.Empty => false
case _ => true
})
object IsInitialized
extends StatusAdminCommands.FromStatus({
case v0.NodeStatus.Response.Success(_) => true
case _ => false
})
class FromStatus(predicate: v0.NodeStatus.Response => Boolean) extends GetStatusBase[Boolean] {
override def handleResponse(response: v0.NodeStatus): Either[String, Boolean] =
(response.response match {
case v0.NodeStatus.Response.Empty => Left(ProtoDeserializationError.FieldNotSet("response"))
case other => Right(predicate(other))
}).leftMap(_.toString)
}
}

View File

@ -0,0 +1,774 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.daml.lf.data.Ref.PackageId
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultUnboundedTimeout,
TimeoutType,
}
import com.digitalasset.canton.admin.api.client.data.*
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.crypto.{Fingerprint, KeyPurpose}
import com.digitalasset.canton.protocol.{DynamicDomainParameters as DynamicDomainParametersInternal}
import com.digitalasset.canton.topology.admin.grpc.BaseQuery
import com.digitalasset.canton.topology.admin.v0
import com.digitalasset.canton.topology.admin.v0.AuthorizationSuccess
import com.digitalasset.canton.topology.admin.v0.InitializationServiceGrpc.InitializationServiceStub
import com.digitalasset.canton.topology.admin.v0.TopologyAggregationServiceGrpc.TopologyAggregationServiceStub
import com.digitalasset.canton.topology.admin.v0.TopologyManagerReadServiceGrpc.TopologyManagerReadServiceStub
import com.digitalasset.canton.topology.admin.v0.TopologyManagerWriteServiceGrpc.TopologyManagerWriteServiceStub
import com.digitalasset.canton.topology.store.StoredTopologyTransactions
import com.digitalasset.canton.topology.transaction.*
import com.digitalasset.canton.topology.{DomainId, *}
import com.google.protobuf.ByteString
import com.google.protobuf.empty.Empty
import com.google.protobuf.timestamp.Timestamp
import io.grpc.ManagedChannel
import java.time.Instant
import scala.concurrent.Future
object TopologyAdminCommands {
object Aggregation {
abstract class BaseCommand[Req, Res, Result] extends GrpcAdminCommand[Req, Res, Result] {
override type Svc = TopologyAggregationServiceStub
override def createService(channel: ManagedChannel): TopologyAggregationServiceStub =
v0.TopologyAggregationServiceGrpc.stub(channel)
}
final case class ListParties(
filterDomain: String,
filterParty: String,
filterParticipant: String,
asOf: Option[Instant],
limit: PositiveInt,
) extends BaseCommand[v0.ListPartiesRequest, v0.ListPartiesResponse, Seq[ListPartiesResult]] {
override def createRequest(): Either[String, v0.ListPartiesRequest] =
Right(
v0.ListPartiesRequest(
filterDomain = filterDomain,
filterParty = filterParty,
filterParticipant = filterParticipant,
asOf = asOf.map(ts => Timestamp(ts.getEpochSecond)),
limit = limit.value,
)
)
override def submitRequest(
service: TopologyAggregationServiceStub,
request: v0.ListPartiesRequest,
): Future[v0.ListPartiesResponse] =
service.listParties(request)
override def handleResponse(
response: v0.ListPartiesResponse
): Either[String, Seq[ListPartiesResult]] =
response.results.traverse(ListPartiesResult.fromProtoV0).leftMap(_.toString)
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class ListKeyOwners(
filterDomain: String,
filterKeyOwnerType: Option[MemberCode],
filterKeyOwnerUid: String,
asOf: Option[Instant],
limit: PositiveInt,
) extends BaseCommand[v0.ListKeyOwnersRequest, v0.ListKeyOwnersResponse, Seq[
ListKeyOwnersResult
]] {
override def createRequest(): Either[String, v0.ListKeyOwnersRequest] =
Right(
v0.ListKeyOwnersRequest(
filterDomain = filterDomain,
filterKeyOwnerType = filterKeyOwnerType.map(_.toProtoPrimitive).getOrElse(""),
filterKeyOwnerUid = filterKeyOwnerUid,
asOf = asOf.map(ts => Timestamp(ts.getEpochSecond)),
limit = limit.value,
)
)
override def submitRequest(
service: TopologyAggregationServiceStub,
request: v0.ListKeyOwnersRequest,
): Future[v0.ListKeyOwnersResponse] =
service.listKeyOwners(request)
override def handleResponse(
response: v0.ListKeyOwnersResponse
): Either[String, Seq[ListKeyOwnersResult]] =
response.results.traverse(ListKeyOwnersResult.fromProtoV0).leftMap(_.toString)
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
}
object Write {
abstract class BaseWriteCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = TopologyManagerWriteServiceStub
override def createService(channel: ManagedChannel): TopologyManagerWriteServiceStub =
v0.TopologyManagerWriteServiceGrpc.stub(channel)
}
abstract class BaseCommand[Req]
extends BaseWriteCommand[Req, v0.AuthorizationSuccess, ByteString] {
protected def authData(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
replaceExisting: Boolean,
force: Boolean,
) =
Some(
v0.AuthorizationData(
ops.toProto,
signedBy.map(_.unwrap).getOrElse(""),
replaceExisting = replaceExisting,
forceChange = force,
)
)
override def handleResponse(response: v0.AuthorizationSuccess): Either[String, ByteString] =
Right(response.serialized)
}
final case class AuthorizeNamespaceDelegation(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
namespace: Fingerprint,
authorizedKey: Fingerprint,
isRootDelegation: Boolean,
force: Boolean,
) extends BaseCommand[v0.NamespaceDelegationAuthorization] {
override def createRequest(): Either[String, v0.NamespaceDelegationAuthorization] =
Right(
v0.NamespaceDelegationAuthorization(
authData(ops, signedBy, replaceExisting = false, force = force),
namespace.toProtoPrimitive,
authorizedKey.toProtoPrimitive,
isRootDelegation,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.NamespaceDelegationAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizeNamespaceDelegation(request)
}
final case class AuthorizeIdentifierDelegation(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
identifier: UniqueIdentifier,
authorizedKey: Fingerprint,
) extends BaseCommand[v0.IdentifierDelegationAuthorization] {
override def createRequest(): Either[String, v0.IdentifierDelegationAuthorization] =
Right(
v0.IdentifierDelegationAuthorization(
authData(ops, signedBy, replaceExisting = false, force = false),
identifier.toProtoPrimitive,
authorizedKey.toProtoPrimitive,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.IdentifierDelegationAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizeIdentifierDelegation(request)
}
final case class AuthorizeOwnerToKeyMapping(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
keyOwner: Member,
fingerprintOfKey: Fingerprint,
purpose: KeyPurpose,
force: Boolean,
) extends BaseCommand[v0.OwnerToKeyMappingAuthorization] {
override def createRequest(): Either[String, v0.OwnerToKeyMappingAuthorization] = Right(
v0.OwnerToKeyMappingAuthorization(
authData(ops, signedBy, replaceExisting = false, force = force),
keyOwner.toProtoPrimitive,
fingerprintOfKey.toProtoPrimitive,
purpose.toProtoEnum,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.OwnerToKeyMappingAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizeOwnerToKeyMapping(request)
}
final case class AuthorizePartyToParticipant(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
side: RequestSide,
party: PartyId,
participant: ParticipantId,
permission: ParticipantPermission,
replaceExisting: Boolean,
force: Boolean,
) extends BaseCommand[v0.PartyToParticipantAuthorization] {
override def createRequest(): Either[String, v0.PartyToParticipantAuthorization] =
Right(
v0.PartyToParticipantAuthorization(
authData(ops, signedBy, replaceExisting = replaceExisting, force = force),
side.toProtoEnum,
party.uid.toProtoPrimitive,
participant.toProtoPrimitive,
permission.toProtoEnum,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.PartyToParticipantAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizePartyToParticipant(request)
}
final case class AuthorizeParticipantDomainState(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
side: RequestSide,
domain: DomainId,
participant: ParticipantId,
permission: ParticipantPermission,
trustLevel: TrustLevel,
replaceExisting: Boolean,
) extends BaseCommand[v0.ParticipantDomainStateAuthorization] {
override def createRequest(): Either[String, v0.ParticipantDomainStateAuthorization] =
Right(
v0.ParticipantDomainStateAuthorization(
authData(ops, signedBy, replaceExisting = replaceExisting, force = false),
side.toProtoEnum,
domain.unwrap.toProtoPrimitive,
participant.toProtoPrimitive,
permission.toProtoEnum,
trustLevel.toProtoEnum,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.ParticipantDomainStateAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizeParticipantDomainState(request)
}
final case class AuthorizeMediatorDomainState(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
side: RequestSide,
domain: DomainId,
mediator: MediatorId,
replaceExisting: Boolean,
) extends BaseCommand[v0.MediatorDomainStateAuthorization] {
override def createRequest(): Either[String, v0.MediatorDomainStateAuthorization] =
Right(
v0.MediatorDomainStateAuthorization(
authData(ops, signedBy, replaceExisting = replaceExisting, force = false),
side.toProtoEnum,
domain.unwrap.toProtoPrimitive,
mediator.uid.toProtoPrimitive,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.MediatorDomainStateAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizeMediatorDomainState(request)
}
final case class AuthorizeVettedPackages(
ops: TopologyChangeOp,
signedBy: Option[Fingerprint],
participant: ParticipantId,
packageIds: Seq[PackageId],
force: Boolean,
) extends BaseCommand[v0.VettedPackagesAuthorization] {
override def createRequest(): Either[String, v0.VettedPackagesAuthorization] =
Right(
v0.VettedPackagesAuthorization(
authData(ops, signedBy, replaceExisting = false, force = force),
participant.uid.toProtoPrimitive,
packageIds = packageIds,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.VettedPackagesAuthorization,
): Future[v0.AuthorizationSuccess] =
service.authorizeVettedPackages(request)
}
final case class AuthorizeDomainParametersChange(
signedBy: Option[Fingerprint],
domainId: DomainId,
newParameters: DynamicDomainParameters,
force: Boolean,
) extends BaseCommand[v0.DomainParametersChangeAuthorization] {
override def createRequest(): Either[String, v0.DomainParametersChangeAuthorization] =
v0.DomainParametersChangeAuthorization(
authorization =
authData(TopologyChangeOp.Replace, signedBy, replaceExisting = false, force = force),
domain = domainId.toProtoPrimitive,
parameters = newParameters.toProto,
).asRight
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.DomainParametersChangeAuthorization,
): Future[AuthorizationSuccess] = service.authorizeDomainParametersChange(request)
}
final case class AuthorizeDomainParametersChangeInternal(
signedBy: Option[Fingerprint],
domainId: DomainId,
newParameters: DynamicDomainParametersInternal,
force: Boolean,
) extends BaseCommand[v0.DomainParametersChangeAuthorization] {
override def createRequest(): Either[String, v0.DomainParametersChangeAuthorization] = {
val parameters =
v0.DomainParametersChangeAuthorization.Parameters
.ParametersV1(newParameters.toProtoV2)
v0.DomainParametersChangeAuthorization(
authorization =
authData(TopologyChangeOp.Replace, signedBy, replaceExisting = false, force = force),
domain = domainId.toProtoPrimitive,
parameters = parameters,
).asRight
}
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.DomainParametersChangeAuthorization,
): Future[AuthorizationSuccess] = service.authorizeDomainParametersChange(request)
}
final case class AddSignedTopologyTransaction(bytes: ByteString)
extends BaseWriteCommand[v0.SignedTopologyTransactionAddition, v0.AdditionSuccess, Unit] {
override def createRequest(): Either[String, v0.SignedTopologyTransactionAddition] =
Right(v0.SignedTopologyTransactionAddition(serialized = bytes))
override def submitRequest(
service: TopologyManagerWriteServiceStub,
request: v0.SignedTopologyTransactionAddition,
): Future[v0.AdditionSuccess] =
service.addSignedTopologyTransaction(request)
override def handleResponse(response: v0.AdditionSuccess): Either[String, Unit] =
Right(())
}
}
object Read {
abstract class BaseCommand[Req, Res, Ret] extends GrpcAdminCommand[Req, Res, Ret] {
override type Svc = TopologyManagerReadServiceStub
override def createService(channel: ManagedChannel): TopologyManagerReadServiceStub =
v0.TopologyManagerReadServiceGrpc.stub(channel)
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class ListPartyToParticipant(
query: BaseQuery,
filterParty: String,
filterParticipant: String,
filterRequestSide: Option[RequestSide],
filterPermission: Option[ParticipantPermission],
) extends BaseCommand[v0.ListPartyToParticipantRequest, v0.ListPartyToParticipantResult, Seq[
ListPartyToParticipantResult
]] {
override def createRequest(): Either[String, v0.ListPartyToParticipantRequest] =
Right(
new v0.ListPartyToParticipantRequest(
baseQuery = Some(query.toProtoV0),
filterParty,
filterParticipant,
filterRequestSide
.map(_.toProtoEnum)
.map(new v0.ListPartyToParticipantRequest.FilterRequestSide(_)),
filterPermission
.map(_.toProtoEnum)
.map(new v0.ListPartyToParticipantRequest.FilterPermission(_)),
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListPartyToParticipantRequest,
): Future[v0.ListPartyToParticipantResult] =
service.listPartyToParticipant(request)
override def handleResponse(
response: v0.ListPartyToParticipantResult
): Either[String, Seq[ListPartyToParticipantResult]] =
response.results.traverse(ListPartyToParticipantResult.fromProtoV0).leftMap(_.toString)
}
final case class ListOwnerToKeyMapping(
query: BaseQuery,
filterKeyOwnerType: Option[MemberCode],
filterKeyOwnerUid: String,
filterKeyPurpose: Option[KeyPurpose],
) extends BaseCommand[v0.ListOwnerToKeyMappingRequest, v0.ListOwnerToKeyMappingResult, Seq[
ListOwnerToKeyMappingResult
]] {
override def createRequest(): Either[String, v0.ListOwnerToKeyMappingRequest] =
Right(
new v0.ListOwnerToKeyMappingRequest(
baseQuery = Some(query.toProtoV0),
filterKeyOwnerType = filterKeyOwnerType.map(_.toProtoPrimitive).getOrElse(""),
filterKeyOwnerUid = filterKeyOwnerUid,
filterKeyPurpose
.map(_.toProtoEnum)
.map(new admin.v0.ListOwnerToKeyMappingRequest.FilterKeyPurpose(_)),
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListOwnerToKeyMappingRequest,
): Future[v0.ListOwnerToKeyMappingResult] =
service.listOwnerToKeyMapping(request)
override def handleResponse(
response: v0.ListOwnerToKeyMappingResult
): Either[String, Seq[ListOwnerToKeyMappingResult]] =
response.results.traverse(ListOwnerToKeyMappingResult.fromProtoV0).leftMap(_.toString)
}
final case class ListNamespaceDelegation(query: BaseQuery, filterNamespace: String)
extends BaseCommand[
v0.ListNamespaceDelegationRequest,
v0.ListNamespaceDelegationResult,
Seq[ListNamespaceDelegationResult],
] {
override def createRequest(): Either[String, v0.ListNamespaceDelegationRequest] =
Right(
new v0.ListNamespaceDelegationRequest(
baseQuery = Some(query.toProtoV0),
filterNamespace = filterNamespace,
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListNamespaceDelegationRequest,
): Future[v0.ListNamespaceDelegationResult] =
service.listNamespaceDelegation(request)
override def handleResponse(
response: v0.ListNamespaceDelegationResult
): Either[String, Seq[ListNamespaceDelegationResult]] =
response.results.traverse(ListNamespaceDelegationResult.fromProtoV0).leftMap(_.toString)
}
final case class ListIdentifierDelegation(query: BaseQuery, filterUid: String)
extends BaseCommand[
v0.ListIdentifierDelegationRequest,
v0.ListIdentifierDelegationResult,
Seq[ListIdentifierDelegationResult],
] {
override def createRequest(): Either[String, v0.ListIdentifierDelegationRequest] =
Right(
new v0.ListIdentifierDelegationRequest(
baseQuery = Some(query.toProtoV0),
filterUid = filterUid,
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListIdentifierDelegationRequest,
): Future[v0.ListIdentifierDelegationResult] =
service.listIdentifierDelegation(request)
override def handleResponse(
response: v0.ListIdentifierDelegationResult
): Either[String, Seq[ListIdentifierDelegationResult]] =
response.results.traverse(ListIdentifierDelegationResult.fromProtoV0).leftMap(_.toString)
}
final case class ListSignedLegalIdentityClaim(query: BaseQuery, filterUid: String)
extends BaseCommand[
v0.ListSignedLegalIdentityClaimRequest,
v0.ListSignedLegalIdentityClaimResult,
Seq[ListSignedLegalIdentityClaimResult],
] {
override def createRequest(): Either[String, v0.ListSignedLegalIdentityClaimRequest] =
Right(
new v0.ListSignedLegalIdentityClaimRequest(
baseQuery = Some(query.toProtoV0),
filterUid = filterUid,
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListSignedLegalIdentityClaimRequest,
): Future[v0.ListSignedLegalIdentityClaimResult] =
service.listSignedLegalIdentityClaim(request)
override def handleResponse(
response: v0.ListSignedLegalIdentityClaimResult
): Either[String, Seq[ListSignedLegalIdentityClaimResult]] =
response.results
.traverse(ListSignedLegalIdentityClaimResult.fromProtoV0)
.leftMap(_.toString)
}
final case class ListVettedPackages(query: BaseQuery, filterParticipant: String)
extends BaseCommand[v0.ListVettedPackagesRequest, v0.ListVettedPackagesResult, Seq[
ListVettedPackagesResult
]] {
override def createRequest(): Either[String, v0.ListVettedPackagesRequest] =
Right(
new v0.ListVettedPackagesRequest(
baseQuery = Some(query.toProtoV0),
filterParticipant,
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListVettedPackagesRequest,
): Future[v0.ListVettedPackagesResult] =
service.listVettedPackages(request)
override def handleResponse(
response: v0.ListVettedPackagesResult
): Either[String, Seq[ListVettedPackagesResult]] =
response.results.traverse(ListVettedPackagesResult.fromProtoV0).leftMap(_.toString)
}
final case class ListDomainParametersChanges(query: BaseQuery)
extends BaseCommand[
v0.ListDomainParametersChangesRequest,
v0.ListDomainParametersChangesResult,
Seq[ListDomainParametersChangeResult],
] {
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListDomainParametersChangesRequest,
): Future[v0.ListDomainParametersChangesResult] = service.listDomainParametersChanges(request)
override def createRequest(): Either[String, v0.ListDomainParametersChangesRequest] = Right(
v0.ListDomainParametersChangesRequest(Some(query.toProtoV0))
)
override def handleResponse(
response: v0.ListDomainParametersChangesResult
): Either[String, Seq[ListDomainParametersChangeResult]] =
response.results.traverse(ListDomainParametersChangeResult.fromProtoV0).leftMap(_.toString)
}
final case class ListStores()
extends BaseCommand[v0.ListAvailableStoresRequest, v0.ListAvailableStoresResult, Seq[
String
]] {
override def createRequest(): Either[String, v0.ListAvailableStoresRequest] =
Right(v0.ListAvailableStoresRequest())
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListAvailableStoresRequest,
): Future[v0.ListAvailableStoresResult] =
service.listAvailableStores(request)
override def handleResponse(
response: v0.ListAvailableStoresResult
): Either[String, Seq[String]] =
Right(response.storeIds)
}
final case class ListParticipantDomainState(
query: BaseQuery,
filterDomain: String,
filterParticipant: String,
) extends BaseCommand[
v0.ListParticipantDomainStateRequest,
v0.ListParticipantDomainStateResult,
Seq[ListParticipantDomainStateResult],
] {
override def createRequest(): Either[String, v0.ListParticipantDomainStateRequest] =
Right(
new v0.ListParticipantDomainStateRequest(
baseQuery = Some(query.toProtoV0),
filterDomain = filterDomain,
filterParticipant = filterParticipant,
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListParticipantDomainStateRequest,
): Future[v0.ListParticipantDomainStateResult] =
service.listParticipantDomainState(request)
override def handleResponse(
response: v0.ListParticipantDomainStateResult
): Either[String, Seq[ListParticipantDomainStateResult]] =
response.results.traverse(ListParticipantDomainStateResult.fromProtoV0).leftMap(_.toString)
}
final case class ListMediatorDomainState(
query: BaseQuery,
filterDomain: String,
filterMediator: String,
) extends BaseCommand[
v0.ListMediatorDomainStateRequest,
v0.ListMediatorDomainStateResult,
Seq[ListMediatorDomainStateResult],
] {
override def createRequest(): Either[String, v0.ListMediatorDomainStateRequest] =
Right(
new v0.ListMediatorDomainStateRequest(
baseQuery = Some(query.toProtoV0),
filterDomain = filterDomain,
filterMediator = filterMediator,
)
)
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListMediatorDomainStateRequest,
): Future[v0.ListMediatorDomainStateResult] =
service.listMediatorDomainState(request)
override def handleResponse(
response: v0.ListMediatorDomainStateResult
): Either[String, Seq[ListMediatorDomainStateResult]] =
response.results.traverse(ListMediatorDomainStateResult.fromProtoV0).leftMap(_.toString)
}
final case class ListAll(query: BaseQuery)
extends BaseCommand[
v0.ListAllRequest,
v0.ListAllResponse,
StoredTopologyTransactions[
TopologyChangeOp
],
] {
override def createRequest(): Either[String, v0.ListAllRequest] =
Right(new v0.ListAllRequest(Some(query.toProtoV0)))
override def submitRequest(
service: TopologyManagerReadServiceStub,
request: v0.ListAllRequest,
): Future[v0.ListAllResponse] = service.listAll(request)
override def handleResponse(
response: v0.ListAllResponse
): Either[String, StoredTopologyTransactions[TopologyChangeOp]] =
response.result
.fold[Either[String, StoredTopologyTransactions[TopologyChangeOp]]](
Right(StoredTopologyTransactions.empty)
) { collection =>
StoredTopologyTransactions.fromProtoV0(collection).leftMap(_.toString)
}
}
}
object Init {
abstract class BaseInitializationService[Req, Resp, Res]
extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = InitializationServiceStub
override def createService(channel: ManagedChannel): InitializationServiceStub =
v0.InitializationServiceGrpc.stub(channel)
}
final case class InitId(identifier: String, fingerprint: String)
extends BaseInitializationService[v0.InitIdRequest, v0.InitIdResponse, UniqueIdentifier] {
override def createRequest(): Either[String, v0.InitIdRequest] =
Right(v0.InitIdRequest(identifier, fingerprint, instance = ""))
override def submitRequest(
service: InitializationServiceStub,
request: v0.InitIdRequest,
): Future[v0.InitIdResponse] =
service.initId(request)
override def handleResponse(response: v0.InitIdResponse): Either[String, UniqueIdentifier] =
UniqueIdentifier.fromProtoPrimitive_(response.uniqueIdentifier)
}
final case class GetId()
extends BaseInitializationService[Empty, v0.GetIdResponse, UniqueIdentifier] {
override def createRequest(): Either[String, Empty] =
Right(Empty())
override def submitRequest(
service: InitializationServiceStub,
request: Empty,
): Future[v0.GetIdResponse] =
service.getId(request)
override def handleResponse(
response: v0.GetIdResponse
): Either[String, UniqueIdentifier] = {
if (response.uniqueIdentifier.nonEmpty)
UniqueIdentifier.fromProtoPrimitive_(response.uniqueIdentifier)
else
Left(
s"Node ${response.instance} is not initialized and therefore does not have an Id assigned yet."
)
}
}
}
}

View File

@ -0,0 +1,771 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultUnboundedTimeout,
TimeoutType,
}
import com.digitalasset.canton.admin.api.client.data.topologyx.*
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.crypto.Fingerprint
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.topology.admin.grpc.BaseQueryX
import com.digitalasset.canton.topology.admin.v1
import com.digitalasset.canton.topology.admin.v1.AuthorizeRequest.Type.{Proposal, TransactionHash}
import com.digitalasset.canton.topology.admin.v1.IdentityInitializationServiceXGrpc.IdentityInitializationServiceXStub
import com.digitalasset.canton.topology.admin.v1.TopologyManagerReadServiceXGrpc.TopologyManagerReadServiceXStub
import com.digitalasset.canton.topology.admin.v1.TopologyManagerWriteServiceXGrpc.TopologyManagerWriteServiceXStub
import com.digitalasset.canton.topology.admin.v1.{
AddTransactionsRequest,
AddTransactionsResponse,
AuthorizeRequest,
AuthorizeResponse,
ListTrafficStateRequest,
SignTransactionsRequest,
SignTransactionsResponse,
}
import com.digitalasset.canton.topology.store.StoredTopologyTransactionsX
import com.digitalasset.canton.topology.store.StoredTopologyTransactionsX.GenericStoredTopologyTransactionsX
import com.digitalasset.canton.topology.transaction.SignedTopologyTransactionX.GenericSignedTopologyTransactionX
import com.digitalasset.canton.topology.transaction.{
SignedTopologyTransactionX,
TopologyChangeOpX,
TopologyMappingX,
}
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
import scala.reflect.ClassTag
object TopologyAdminCommandsX {
object Read {
abstract class BaseCommand[Req, Res, Ret] extends GrpcAdminCommand[Req, Res, Ret] {
override type Svc = TopologyManagerReadServiceXStub
override def createService(channel: ManagedChannel): TopologyManagerReadServiceXStub =
v1.TopologyManagerReadServiceXGrpc.stub(channel)
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class ListTrafficControlState(
query: BaseQueryX,
filterMember: String,
) extends BaseCommand[
v1.ListTrafficStateRequest,
v1.ListTrafficStateResult,
Seq[ListTrafficStateResult],
] {
override def createRequest(): Either[String, v1.ListTrafficStateRequest] =
Right(
new ListTrafficStateRequest(
baseQuery = Some(query.toProtoV1),
filterMember = filterMember,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListTrafficStateRequest,
): Future[v1.ListTrafficStateResult] =
service.listTrafficState(request)
override def handleResponse(
response: v1.ListTrafficStateResult
): Either[String, Seq[ListTrafficStateResult]] =
response.results
.traverse(ListTrafficStateResult.fromProtoV1)
.leftMap(_.toString)
}
final case class ListNamespaceDelegation(
query: BaseQueryX,
filterNamespace: String,
filterTargetKey: Option[Fingerprint],
) extends BaseCommand[
v1.ListNamespaceDelegationRequest,
v1.ListNamespaceDelegationResult,
Seq[ListNamespaceDelegationResult],
] {
override def createRequest(): Either[String, v1.ListNamespaceDelegationRequest] =
Right(
new v1.ListNamespaceDelegationRequest(
baseQuery = Some(query.toProtoV1),
filterNamespace = filterNamespace,
filterTargetKeyFingerprint = filterTargetKey.map(_.toProtoPrimitive).getOrElse(""),
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListNamespaceDelegationRequest,
): Future[v1.ListNamespaceDelegationResult] =
service.listNamespaceDelegation(request)
override def handleResponse(
response: v1.ListNamespaceDelegationResult
): Either[String, Seq[ListNamespaceDelegationResult]] =
response.results.traverse(ListNamespaceDelegationResult.fromProtoV1).leftMap(_.toString)
}
final case class ListUnionspaceDefinition(
query: BaseQueryX,
filterNamespace: String,
) extends BaseCommand[
v1.ListUnionspaceDefinitionRequest,
v1.ListUnionspaceDefinitionResult,
Seq[ListUnionspaceDefinitionResult],
] {
override def createRequest(): Either[String, v1.ListUnionspaceDefinitionRequest] =
Right(
new v1.ListUnionspaceDefinitionRequest(
baseQuery = Some(query.toProtoV1),
filterNamespace = filterNamespace,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListUnionspaceDefinitionRequest,
): Future[v1.ListUnionspaceDefinitionResult] =
service.listUnionspaceDefinition(request)
override def handleResponse(
response: v1.ListUnionspaceDefinitionResult
): Either[String, Seq[ListUnionspaceDefinitionResult]] =
response.results.traverse(ListUnionspaceDefinitionResult.fromProtoV1).leftMap(_.toString)
}
final case class ListIdentifierDelegation(
query: BaseQueryX,
filterUid: String,
filterTargetKey: Option[Fingerprint],
) extends BaseCommand[
v1.ListIdentifierDelegationRequest,
v1.ListIdentifierDelegationResult,
Seq[ListIdentifierDelegationResult],
] {
override def createRequest(): Either[String, v1.ListIdentifierDelegationRequest] =
Right(
new v1.ListIdentifierDelegationRequest(
baseQuery = Some(query.toProtoV1),
filterUid = filterUid,
filterTargetKeyFingerprint = filterTargetKey.map(_.toProtoPrimitive).getOrElse(""),
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListIdentifierDelegationRequest,
): Future[v1.ListIdentifierDelegationResult] =
service.listIdentifierDelegation(request)
override def handleResponse(
response: v1.ListIdentifierDelegationResult
): Either[String, Seq[ListIdentifierDelegationResult]] =
response.results.traverse(ListIdentifierDelegationResult.fromProtoV1).leftMap(_.toString)
}
final case class ListOwnerToKeyMapping(
query: BaseQueryX,
filterKeyOwnerType: Option[MemberCode],
filterKeyOwnerUid: String,
) extends BaseCommand[v1.ListOwnerToKeyMappingRequest, v1.ListOwnerToKeyMappingResult, Seq[
ListOwnerToKeyMappingResult
]] {
override def createRequest(): Either[String, v1.ListOwnerToKeyMappingRequest] =
Right(
new v1.ListOwnerToKeyMappingRequest(
baseQuery = Some(query.toProtoV1),
filterKeyOwnerType = filterKeyOwnerType.map(_.toProtoPrimitive).getOrElse(""),
filterKeyOwnerUid = filterKeyOwnerUid,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListOwnerToKeyMappingRequest,
): Future[v1.ListOwnerToKeyMappingResult] =
service.listOwnerToKeyMapping(request)
override def handleResponse(
response: v1.ListOwnerToKeyMappingResult
): Either[String, Seq[ListOwnerToKeyMappingResult]] =
response.results.traverse(ListOwnerToKeyMappingResult.fromProtoV1).leftMap(_.toString)
}
final case class ListDomainTrustCertificate(
query: BaseQueryX,
filterUid: String,
) extends BaseCommand[
v1.ListDomainTrustCertificateRequest,
v1.ListDomainTrustCertificateResult,
Seq[ListDomainTrustCertificateResult],
] {
override def createRequest(): Either[String, v1.ListDomainTrustCertificateRequest] =
Right(
new v1.ListDomainTrustCertificateRequest(
baseQuery = Some(query.toProtoV1),
filterUid = filterUid,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListDomainTrustCertificateRequest,
): Future[v1.ListDomainTrustCertificateResult] =
service.listDomainTrustCertificate(request)
override def handleResponse(
response: v1.ListDomainTrustCertificateResult
): Either[String, Seq[ListDomainTrustCertificateResult]] =
response.results.traverse(ListDomainTrustCertificateResult.fromProtoV1).leftMap(_.toString)
}
final case class ListParticipantDomainPermission(
query: BaseQueryX,
filterUid: String,
) extends BaseCommand[
v1.ListParticipantDomainPermissionRequest,
v1.ListParticipantDomainPermissionResult,
Seq[ListParticipantDomainPermissionResult],
] {
override def createRequest(): Either[String, v1.ListParticipantDomainPermissionRequest] =
Right(
new v1.ListParticipantDomainPermissionRequest(
baseQuery = Some(query.toProtoV1),
filterUid = filterUid,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListParticipantDomainPermissionRequest,
): Future[v1.ListParticipantDomainPermissionResult] =
service.listParticipantDomainPermission(request)
override def handleResponse(
response: v1.ListParticipantDomainPermissionResult
): Either[String, Seq[ListParticipantDomainPermissionResult]] =
response.results
.traverse(ListParticipantDomainPermissionResult.fromProtoV1)
.leftMap(_.toString)
}
final case class ListPartyHostingLimits(
query: BaseQueryX,
filterUid: String,
) extends BaseCommand[
v1.ListPartyHostingLimitsRequest,
v1.ListPartyHostingLimitsResult,
Seq[ListPartyHostingLimitsResult],
] {
override def createRequest(): Either[String, v1.ListPartyHostingLimitsRequest] =
Right(
new v1.ListPartyHostingLimitsRequest(
baseQuery = Some(query.toProtoV1),
filterUid = filterUid,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListPartyHostingLimitsRequest,
): Future[v1.ListPartyHostingLimitsResult] =
service.listPartyHostingLimits(request)
override def handleResponse(
response: v1.ListPartyHostingLimitsResult
): Either[String, Seq[ListPartyHostingLimitsResult]] =
response.results
.traverse(ListPartyHostingLimitsResult.fromProtoV1)
.leftMap(_.toString)
}
final case class ListVettedPackages(
query: BaseQueryX,
filterParticipant: String,
) extends BaseCommand[
v1.ListVettedPackagesRequest,
v1.ListVettedPackagesResult,
Seq[ListVettedPackagesResult],
] {
override def createRequest(): Either[String, v1.ListVettedPackagesRequest] =
Right(
new v1.ListVettedPackagesRequest(
baseQuery = Some(query.toProtoV1),
filterParticipant = filterParticipant,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListVettedPackagesRequest,
): Future[v1.ListVettedPackagesResult] =
service.listVettedPackages(request)
override def handleResponse(
response: v1.ListVettedPackagesResult
): Either[String, Seq[ListVettedPackagesResult]] =
response.results
.traverse(ListVettedPackagesResult.fromProtoV1)
.leftMap(_.toString)
}
final case class ListPartyToParticipant(
query: BaseQueryX,
filterParty: String,
filterParticipant: String,
) extends BaseCommand[
v1.ListPartyToParticipantRequest,
v1.ListPartyToParticipantResult,
Seq[ListPartyToParticipantResult],
] {
override def createRequest(): Either[String, v1.ListPartyToParticipantRequest] =
Right(
new v1.ListPartyToParticipantRequest(
baseQuery = Some(query.toProtoV1),
filterParty = filterParty,
filterParticipant = filterParticipant,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListPartyToParticipantRequest,
): Future[v1.ListPartyToParticipantResult] =
service.listPartyToParticipant(request)
override def handleResponse(
response: v1.ListPartyToParticipantResult
): Either[String, Seq[ListPartyToParticipantResult]] =
response.results
.traverse(ListPartyToParticipantResult.fromProtoV1)
.leftMap(_.toString)
}
final case class ListAuthorityOf(
query: BaseQueryX,
filterParty: String,
) extends BaseCommand[
v1.ListAuthorityOfRequest,
v1.ListAuthorityOfResult,
Seq[ListAuthorityOfResult],
] {
override def createRequest(): Either[String, v1.ListAuthorityOfRequest] =
Right(
new v1.ListAuthorityOfRequest(
baseQuery = Some(query.toProtoV1),
filterParty = filterParty,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListAuthorityOfRequest,
): Future[v1.ListAuthorityOfResult] =
service.listAuthorityOf(request)
override def handleResponse(
response: v1.ListAuthorityOfResult
): Either[String, Seq[ListAuthorityOfResult]] =
response.results
.traverse(ListAuthorityOfResult.fromProtoV1)
.leftMap(_.toString)
}
final case class DomainParametersState(
query: BaseQueryX,
filterDomain: String,
) extends BaseCommand[
v1.ListDomainParametersStateRequest,
v1.ListDomainParametersStateResult,
Seq[ListDomainParametersStateResult],
] {
override def createRequest(): Either[String, v1.ListDomainParametersStateRequest] =
Right(
new v1.ListDomainParametersStateRequest(
baseQuery = Some(query.toProtoV1),
filterDomain = filterDomain,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListDomainParametersStateRequest,
): Future[v1.ListDomainParametersStateResult] =
service.listDomainParametersState(request)
override def handleResponse(
response: v1.ListDomainParametersStateResult
): Either[String, Seq[ListDomainParametersStateResult]] =
response.results
.traverse(ListDomainParametersStateResult.fromProtoV1)
.leftMap(_.toString)
}
final case class MediatorDomainState(
query: BaseQueryX,
filterDomain: String,
) extends BaseCommand[
v1.ListMediatorDomainStateRequest,
v1.ListMediatorDomainStateResult,
Seq[ListMediatorDomainStateResult],
] {
override def createRequest(): Either[String, v1.ListMediatorDomainStateRequest] =
Right(
new v1.ListMediatorDomainStateRequest(
baseQuery = Some(query.toProtoV1),
filterDomain = filterDomain,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListMediatorDomainStateRequest,
): Future[v1.ListMediatorDomainStateResult] =
service.listMediatorDomainState(request)
override def handleResponse(
response: v1.ListMediatorDomainStateResult
): Either[String, Seq[ListMediatorDomainStateResult]] =
response.results
.traverse(ListMediatorDomainStateResult.fromProtoV1)
.leftMap(_.toString)
}
final case class SequencerDomainState(
query: BaseQueryX,
filterDomain: String,
) extends BaseCommand[
v1.ListSequencerDomainStateRequest,
v1.ListSequencerDomainStateResult,
Seq[ListSequencerDomainStateResult],
] {
override def createRequest(): Either[String, v1.ListSequencerDomainStateRequest] =
Right(
new v1.ListSequencerDomainStateRequest(
baseQuery = Some(query.toProtoV1),
filterDomain = filterDomain,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListSequencerDomainStateRequest,
): Future[v1.ListSequencerDomainStateResult] =
service.listSequencerDomainState(request)
override def handleResponse(
response: v1.ListSequencerDomainStateResult
): Either[String, Seq[ListSequencerDomainStateResult]] =
response.results
.traverse(ListSequencerDomainStateResult.fromProtoV1)
.leftMap(_.toString)
}
final case class PurgeTopologyTransactionX(
query: BaseQueryX,
filterDomain: String,
) extends BaseCommand[
v1.ListPurgeTopologyTransactionXRequest,
v1.ListPurgeTopologyTransactionXResult,
Seq[ListPurgeTopologyTransactionXResult],
] {
override def createRequest(): Either[String, v1.ListPurgeTopologyTransactionXRequest] =
Right(
new v1.ListPurgeTopologyTransactionXRequest(
baseQuery = Some(query.toProtoV1),
filterDomain = filterDomain,
)
)
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListPurgeTopologyTransactionXRequest,
): Future[v1.ListPurgeTopologyTransactionXResult] =
service.listPurgeTopologyTransactionX(request)
override def handleResponse(
response: v1.ListPurgeTopologyTransactionXResult
): Either[String, Seq[ListPurgeTopologyTransactionXResult]] =
response.results
.traverse(ListPurgeTopologyTransactionXResult.fromProtoV1)
.leftMap(_.toString)
}
final case class ListStores()
extends BaseCommand[v1.ListAvailableStoresRequest, v1.ListAvailableStoresResult, Seq[
String
]] {
override def createRequest(): Either[String, v1.ListAvailableStoresRequest] =
Right(v1.ListAvailableStoresRequest())
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListAvailableStoresRequest,
): Future[v1.ListAvailableStoresResult] =
service.listAvailableStores(request)
override def handleResponse(
response: v1.ListAvailableStoresResult
): Either[String, Seq[String]] =
Right(response.storeIds)
}
final case class ListAll(query: BaseQueryX)
extends BaseCommand[
v1.ListAllRequest,
v1.ListAllResponse,
GenericStoredTopologyTransactionsX,
] {
override def createRequest(): Either[String, v1.ListAllRequest] =
Right(new v1.ListAllRequest(Some(query.toProtoV1)))
override def submitRequest(
service: TopologyManagerReadServiceXStub,
request: v1.ListAllRequest,
): Future[v1.ListAllResponse] = service.listAll(request)
override def handleResponse(
response: v1.ListAllResponse
): Either[String, GenericStoredTopologyTransactionsX] =
response.result
.fold[Either[String, GenericStoredTopologyTransactionsX]](
Right(StoredTopologyTransactionsX.empty)
) { collection =>
StoredTopologyTransactionsX.fromProtoV0(collection).leftMap(_.toString)
}
}
}
object Write {
abstract class BaseWriteCommand[Req, Res, Ret] extends GrpcAdminCommand[Req, Res, Ret] {
override type Svc = TopologyManagerWriteServiceXStub
override def createService(channel: ManagedChannel): TopologyManagerWriteServiceXStub =
v1.TopologyManagerWriteServiceXGrpc.stub(channel)
// command will potentially take a long time
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class AddTransactions(
transactions: Seq[GenericSignedTopologyTransactionX],
store: String,
) extends BaseWriteCommand[AddTransactionsRequest, AddTransactionsResponse, Unit] {
override def createRequest(): Either[String, AddTransactionsRequest] = {
Right(AddTransactionsRequest(transactions.map(_.toProtoV2), forceChange = false, store))
}
override def submitRequest(
service: TopologyManagerWriteServiceXStub,
request: AddTransactionsRequest,
): Future[AddTransactionsResponse] = service.addTransactions(request)
override def handleResponse(response: AddTransactionsResponse): Either[String, Unit] =
Right(())
}
final case class SignTransactions(
transactions: Seq[GenericSignedTopologyTransactionX],
signedBy: Seq[Fingerprint],
) extends BaseWriteCommand[SignTransactionsRequest, SignTransactionsResponse, Seq[
GenericSignedTopologyTransactionX
]] {
override def createRequest(): Either[String, SignTransactionsRequest] = {
Right(
SignTransactionsRequest(transactions.map(_.toProtoV2), signedBy.map(_.toProtoPrimitive))
)
}
override def submitRequest(
service: TopologyManagerWriteServiceXStub,
request: SignTransactionsRequest,
): Future[SignTransactionsResponse] = service.signTransactions(request)
override def handleResponse(
response: SignTransactionsResponse
): Either[String, Seq[GenericSignedTopologyTransactionX]] =
response.transactions.traverse(SignedTopologyTransactionX.fromProtoV2).leftMap(_.message)
}
final case class Propose[M <: TopologyMappingX: ClassTag](
mapping: Either[String, M],
signedBy: Seq[Fingerprint],
change: TopologyChangeOpX,
serial: Option[PositiveInt],
mustFullyAuthorize: Boolean,
forceChange: Boolean,
store: String,
) extends BaseWriteCommand[
AuthorizeRequest,
AuthorizeResponse,
SignedTopologyTransactionX[TopologyChangeOpX, M],
] {
override def createRequest(): Either[String, AuthorizeRequest] = mapping.map(m =>
AuthorizeRequest(
Proposal(
AuthorizeRequest.Proposal(
change.toProto,
serial.map(_.value).getOrElse(0),
Some(m.toProtoV2),
)
),
mustFullyAuthorize = mustFullyAuthorize,
forceChange = false,
signedBy = signedBy.map(_.toProtoPrimitive),
store,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceXStub,
request: AuthorizeRequest,
): Future[AuthorizeResponse] = service.authorize(request)
override def handleResponse(
response: AuthorizeResponse
): Either[String, SignedTopologyTransactionX[TopologyChangeOpX, M]] = response.transaction
.toRight("no transaction in response")
.flatMap(
SignedTopologyTransactionX
.fromProtoV2(_)
.leftMap(_.message)
.flatMap(tx =>
tx.selectMapping[M]
.toRight(
s"Expected mapping ${ClassTag[M].getClass.getSimpleName}, but received: ${tx.transaction.mapping.getClass.getSimpleName}"
)
)
)
}
object Propose {
def apply[M <: TopologyMappingX: ClassTag](
mapping: M,
signedBy: Seq[Fingerprint],
store: String,
serial: Option[PositiveInt] = None,
change: TopologyChangeOpX = TopologyChangeOpX.Replace,
mustFullyAuthorize: Boolean = false,
forceChange: Boolean = false,
): Propose[M] =
Propose(Right(mapping), signedBy, change, serial, mustFullyAuthorize, forceChange, store)
}
final case class Authorize[M <: TopologyMappingX: ClassTag](
transactionHash: String,
mustFullyAuthorize: Boolean,
signedBy: Seq[Fingerprint],
store: String,
) extends BaseWriteCommand[
AuthorizeRequest,
AuthorizeResponse,
SignedTopologyTransactionX[TopologyChangeOpX, M],
] {
override def createRequest(): Either[String, AuthorizeRequest] = Right(
AuthorizeRequest(
TransactionHash(transactionHash),
mustFullyAuthorize = mustFullyAuthorize,
forceChange = false,
signedBy = signedBy.map(_.toProtoPrimitive),
store = store,
)
)
override def submitRequest(
service: TopologyManagerWriteServiceXStub,
request: AuthorizeRequest,
): Future[AuthorizeResponse] = service.authorize(request)
override def handleResponse(
response: AuthorizeResponse
): Either[String, SignedTopologyTransactionX[TopologyChangeOpX, M]] = response.transaction
.toRight("no transaction in response")
.flatMap(
SignedTopologyTransactionX
.fromProtoV2(_)
.leftMap(_.message)
.flatMap(tx =>
tx.selectMapping[M]
.toRight(
s"Expected mapping ${ClassTag[M].getClass.getSimpleName}, but received: ${tx.transaction.mapping.getClass.getSimpleName}"
)
)
)
}
}
object Init {
abstract class BaseInitializationService[Req, Resp, Res]
extends GrpcAdminCommand[Req, Resp, Res] {
override type Svc = IdentityInitializationServiceXStub
override def createService(channel: ManagedChannel): IdentityInitializationServiceXStub =
v1.IdentityInitializationServiceXGrpc.stub(channel)
}
final case class InitId(identifier: String)
extends BaseInitializationService[v1.InitIdRequest, v1.InitIdResponse, Unit] {
override def createRequest(): Either[String, v1.InitIdRequest] =
Right(v1.InitIdRequest(identifier))
override def submitRequest(
service: IdentityInitializationServiceXStub,
request: v1.InitIdRequest,
): Future[v1.InitIdResponse] =
service.initId(request)
override def handleResponse(response: v1.InitIdResponse): Either[String, Unit] =
Right(())
}
final case class GetId()
extends BaseInitializationService[Empty, v1.GetIdResponse, UniqueIdentifier] {
override def createRequest(): Either[String, Empty] =
Right(Empty())
override def submitRequest(
service: IdentityInitializationServiceXStub,
request: Empty,
): Future[v1.GetIdResponse] =
service.getId(request)
override def handleResponse(
response: v1.GetIdResponse
): Either[String, UniqueIdentifier] = {
if (response.uniqueIdentifier.nonEmpty)
UniqueIdentifier.fromProtoPrimitive_(response.uniqueIdentifier)
else
Left(
s"Node is not initialized and therefore does not have an Id assigned yet."
)
}
}
}
}

View File

@ -0,0 +1,371 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.commands
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
DefaultUnboundedTimeout,
TimeoutType,
}
import com.digitalasset.canton.crypto.admin.grpc.PrivateKeyMetadata
import com.digitalasset.canton.crypto.admin.v0
import com.digitalasset.canton.crypto.admin.v0.VaultServiceGrpc.VaultServiceStub
import com.digitalasset.canton.crypto.{PublicKeyWithName, v0 as cryptoproto, *}
import com.digitalasset.canton.util.{EitherUtil, OptionUtil}
import com.digitalasset.canton.version.ProtocolVersion
import com.google.protobuf.ByteString
import com.google.protobuf.empty.Empty
import io.grpc.ManagedChannel
import scala.concurrent.Future
object VaultAdminCommands {
abstract class BaseVaultAdminCommand[Req, Res, Result]
extends GrpcAdminCommand[Req, Res, Result] {
override type Svc = VaultServiceStub
override def createService(channel: ManagedChannel): VaultServiceStub =
v0.VaultServiceGrpc.stub(channel)
}
abstract class ListKeys[R, T](
filterFingerprint: String,
filterName: String,
filterPurpose: Set[KeyPurpose] = Set.empty,
) extends BaseVaultAdminCommand[v0.ListKeysRequest, R, Seq[T]] {
override def createRequest(): Either[String, v0.ListKeysRequest] =
Right(
v0.ListKeysRequest(
filterFingerprint = filterFingerprint,
filterName = filterName,
filterPurpose = filterPurpose.map(_.toProtoEnum).toSeq,
)
)
}
// list keys in my key vault
final case class ListMyKeys(
filterFingerprint: String,
filterName: String,
filterPurpose: Set[KeyPurpose] = Set.empty,
) extends ListKeys[v0.ListMyKeysResponse, PrivateKeyMetadata](
filterFingerprint,
filterName,
filterPurpose,
) {
override def submitRequest(
service: VaultServiceStub,
request: v0.ListKeysRequest,
): Future[v0.ListMyKeysResponse] =
service.listMyKeys(request)
override def handleResponse(
response: v0.ListMyKeysResponse
): Either[String, Seq[PrivateKeyMetadata]] =
response.privateKeysMetadata.traverse(PrivateKeyMetadata.fromProtoV0).leftMap(_.toString)
}
// list public keys in key registry
final case class ListPublicKeys(
filterFingerprint: String,
filterName: String,
filterPurpose: Set[KeyPurpose] = Set.empty,
) extends ListKeys[v0.ListKeysResponse, PublicKeyWithName](
filterFingerprint,
filterName,
filterPurpose,
) {
override def submitRequest(
service: VaultServiceStub,
request: v0.ListKeysRequest,
): Future[v0.ListKeysResponse] =
service.listPublicKeys(request)
override def handleResponse(
response: v0.ListKeysResponse
): Either[String, Seq[PublicKeyWithName]] =
response.publicKeys.traverse(PublicKeyWithName.fromProtoV0).leftMap(_.toString)
}
abstract class BaseImportPublicKey
extends BaseVaultAdminCommand[
v0.ImportPublicKeyRequest,
v0.ImportPublicKeyResponse,
Fingerprint,
] {
override def submitRequest(
service: VaultServiceStub,
request: v0.ImportPublicKeyRequest,
): Future[v0.ImportPublicKeyResponse] =
service.importPublicKey(request)
override def handleResponse(response: v0.ImportPublicKeyResponse): Either[String, Fingerprint] =
Fingerprint.fromProtoPrimitive(response.fingerprint).leftMap(_.toString)
}
// upload a public key into the key registry
final case class ImportPublicKey(publicKey: ByteString, name: Option[String])
extends BaseImportPublicKey {
override def createRequest(): Either[String, v0.ImportPublicKeyRequest] =
Right(v0.ImportPublicKeyRequest(publicKey = publicKey, name = name.getOrElse("")))
}
final case class GenerateSigningKey(name: String, scheme: Option[SigningKeyScheme])
extends BaseVaultAdminCommand[
v0.GenerateSigningKeyRequest,
v0.GenerateSigningKeyResponse,
SigningPublicKey,
] {
override def createRequest(): Either[String, v0.GenerateSigningKeyRequest] =
Right(
v0.GenerateSigningKeyRequest(
name = name,
keyScheme = scheme.fold[cryptoproto.SigningKeyScheme](
cryptoproto.SigningKeyScheme.MissingSigningKeyScheme
)(_.toProtoEnum),
)
)
override def submitRequest(
service: VaultServiceStub,
request: v0.GenerateSigningKeyRequest,
): Future[v0.GenerateSigningKeyResponse] = {
service.generateSigningKey(request)
}
override def handleResponse(
response: v0.GenerateSigningKeyResponse
): Either[String, SigningPublicKey] =
response.publicKey
.toRight("No public key returned")
.flatMap(k => SigningPublicKey.fromProtoV0(k).leftMap(_.toString))
// may take some time if we need to wait for entropy
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class GenerateEncryptionKey(name: String, scheme: Option[EncryptionKeyScheme])
extends BaseVaultAdminCommand[
v0.GenerateEncryptionKeyRequest,
v0.GenerateEncryptionKeyResponse,
EncryptionPublicKey,
] {
override def createRequest(): Either[String, v0.GenerateEncryptionKeyRequest] =
Right(
v0.GenerateEncryptionKeyRequest(
name = name,
keyScheme = scheme.fold[cryptoproto.EncryptionKeyScheme](
cryptoproto.EncryptionKeyScheme.MissingEncryptionKeyScheme
)(_.toProtoEnum),
)
)
override def submitRequest(
service: VaultServiceStub,
request: v0.GenerateEncryptionKeyRequest,
): Future[v0.GenerateEncryptionKeyResponse] = {
service.generateEncryptionKey(request)
}
override def handleResponse(
response: v0.GenerateEncryptionKeyResponse
): Either[String, EncryptionPublicKey] =
response.publicKey
.toRight("No public key returned")
.flatMap(k => EncryptionPublicKey.fromProtoV0(k).leftMap(_.toString))
// may time some time if we need to wait for entropy
override def timeoutType: TimeoutType = DefaultUnboundedTimeout
}
final case class RegisterKmsSigningKey(kmsKeyId: String, name: String)
extends BaseVaultAdminCommand[
v0.RegisterKmsSigningKeyRequest,
v0.RegisterKmsSigningKeyResponse,
SigningPublicKey,
] {
override def createRequest(): Either[String, v0.RegisterKmsSigningKeyRequest] =
Right(
v0.RegisterKmsSigningKeyRequest(
kmsKeyId = kmsKeyId,
name = name,
)
)
override def submitRequest(
service: VaultServiceStub,
request: v0.RegisterKmsSigningKeyRequest,
): Future[v0.RegisterKmsSigningKeyResponse] = {
service.registerKmsSigningKey(request)
}
override def handleResponse(
response: v0.RegisterKmsSigningKeyResponse
): Either[String, SigningPublicKey] =
response.publicKey
.toRight("No public key returned")
.flatMap(k => SigningPublicKey.fromProtoV0(k).leftMap(_.toString))
}
final case class RegisterKmsEncryptionKey(kmsKeyId: String, name: String)
extends BaseVaultAdminCommand[
v0.RegisterKmsEncryptionKeyRequest,
v0.RegisterKmsEncryptionKeyResponse,
EncryptionPublicKey,
] {
override def createRequest(): Either[String, v0.RegisterKmsEncryptionKeyRequest] =
Right(
v0.RegisterKmsEncryptionKeyRequest(
kmsKeyId = kmsKeyId,
name = name,
)
)
override def submitRequest(
service: VaultServiceStub,
request: v0.RegisterKmsEncryptionKeyRequest,
): Future[v0.RegisterKmsEncryptionKeyResponse] = {
service.registerKmsEncryptionKey(request)
}
override def handleResponse(
response: v0.RegisterKmsEncryptionKeyResponse
): Either[String, EncryptionPublicKey] =
response.publicKey
.toRight("No public key returned")
.flatMap(k => EncryptionPublicKey.fromProtoV0(k).leftMap(_.toString))
}
final case class RotateWrapperKey(newWrapperKeyId: String)
extends BaseVaultAdminCommand[
v0.RotateWrapperKeyRequest,
Empty,
Unit,
] {
override def createRequest(): Either[String, v0.RotateWrapperKeyRequest] =
Right(
v0.RotateWrapperKeyRequest(
newWrapperKeyId = newWrapperKeyId
)
)
override def submitRequest(
service: VaultServiceStub,
request: v0.RotateWrapperKeyRequest,
): Future[Empty] = {
service.rotateWrapperKey(request)
}
override def handleResponse(response: Empty): Either[String, Unit] = Right(())
}
final case class GetWrapperKeyId()
extends BaseVaultAdminCommand[
v0.GetWrapperKeyIdRequest,
v0.GetWrapperKeyIdResponse,
String,
] {
override def createRequest(): Either[String, v0.GetWrapperKeyIdRequest] =
Right(
v0.GetWrapperKeyIdRequest()
)
override def submitRequest(
service: VaultServiceStub,
request: v0.GetWrapperKeyIdRequest,
): Future[v0.GetWrapperKeyIdResponse] = {
service.getWrapperKeyId(request)
}
override def handleResponse(
response: v0.GetWrapperKeyIdResponse
): Either[String, String] =
Right(response.wrapperKeyId)
}
final case class ImportKeyPair(keyPair: ByteString, name: Option[String])
extends BaseVaultAdminCommand[
v0.ImportKeyPairRequest,
v0.ImportKeyPairResponse,
Unit,
] {
override def createRequest(): Either[String, v0.ImportKeyPairRequest] =
Right(v0.ImportKeyPairRequest(keyPair = keyPair, name = OptionUtil.noneAsEmptyString(name)))
override def submitRequest(
service: VaultServiceStub,
request: v0.ImportKeyPairRequest,
): Future[v0.ImportKeyPairResponse] =
service.importKeyPair(request)
override def handleResponse(response: v0.ImportKeyPairResponse): Either[String, Unit] =
EitherUtil.unit
}
final case class ExportKeyPair(fingerprint: Fingerprint, protocolVersion: ProtocolVersion)
extends BaseVaultAdminCommand[
v0.ExportKeyPairRequest,
v0.ExportKeyPairResponse,
ByteString,
] {
override def createRequest(): Either[String, v0.ExportKeyPairRequest] = {
Right(
v0.ExportKeyPairRequest(
fingerprint = fingerprint.toProtoPrimitive,
protocolVersion = protocolVersion.toProtoPrimitive,
)
)
}
override def submitRequest(
service: VaultServiceStub,
request: v0.ExportKeyPairRequest,
): Future[v0.ExportKeyPairResponse] =
service.exportKeyPair(request)
override def handleResponse(response: v0.ExportKeyPairResponse): Either[String, ByteString] =
Right(response.keyPair)
}
final case class DeleteKeyPair(fingerprint: Fingerprint)
extends BaseVaultAdminCommand[
v0.DeleteKeyPairRequest,
v0.DeleteKeyPairResponse,
Unit,
] {
override def createRequest(): Either[String, v0.DeleteKeyPairRequest] = {
Right(v0.DeleteKeyPairRequest(fingerprint = fingerprint.toProtoPrimitive))
}
override def submitRequest(
service: VaultServiceStub,
request: v0.DeleteKeyPairRequest,
): Future[v0.DeleteKeyPairResponse] =
service.deleteKeyPair(request)
override def handleResponse(response: v0.DeleteKeyPairResponse): Either[String, Unit] =
EitherUtil.unit
}
}

View File

@ -0,0 +1,104 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import cats.Show
import com.digitalasset.canton.admin.api.client.data.CantonStatus.splitSuccessfulAndFailedStatus
import com.digitalasset.canton.console.{DomainReference, ParticipantReference}
import com.digitalasset.canton.health.admin.data.{DomainStatus, NodeStatus, ParticipantStatus}
import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting}
import com.digitalasset.canton.util.ShowUtil.*
trait CantonStatus extends PrettyPrinting {
protected def descriptions[Status <: NodeStatus.Status](
statusMap: Map[String, Status],
failureMap: Map[String, NodeStatus.Failure],
instanceType: String,
): Seq[String] = {
val success = sort(statusMap)
.map { case (d, status) =>
show"Status for ${instanceType.unquoted} ${d.singleQuoted}:\n$status"
}
val failure = sort(failureMap)
.map { case (d, status) =>
show"${instanceType.unquoted} ${d.singleQuoted} cannot be reached: ${status.msg}"
}
success ++ failure
}
private def sort[K: Ordering, V](status: Map[K, V]): Seq[(K, V)] =
status.toSeq.sortBy(_._1)
}
object CantonStatus {
def splitSuccessfulAndFailedStatus[K: Show, S <: NodeStatus.Status](
nodes: Map[K, () => NodeStatus[S]],
instanceType: String,
): (Map[K, S], Map[K, NodeStatus.Failure]) = {
val map: Map[K, NodeStatus[S]] =
nodes.map { case (node, getStatus) =>
node -> getStatus()
}
val status: Map[K, S] =
map.collect { case (n, NodeStatus.Success(status)) =>
n -> status
}
val unreachable: Map[K, NodeStatus.Failure] =
map.collect {
case (s, entry: NodeStatus.Failure) => s -> entry
case (s, _: NodeStatus.NotInitialized) =>
s -> NodeStatus.Failure(
s"${instanceType.unquoted} ${s.show.singleQuoted} has not been initialized"
)
}
(status, unreachable)
}
}
object CommunityCantonStatus {
def getStatus(
domains: Map[String, () => NodeStatus[DomainStatus]],
participants: Map[String, () => NodeStatus[ParticipantStatus]],
): CommunityCantonStatus = {
val (domainStatus, unreachableDomains) =
splitSuccessfulAndFailedStatus(domains, DomainReference.InstanceType)
val (participantStatus, unreachableParticipants) =
splitSuccessfulAndFailedStatus(participants, ParticipantReference.InstanceType)
CommunityCantonStatus(
domainStatus,
unreachableDomains,
participantStatus,
unreachableParticipants,
)
}
}
final case class CommunityCantonStatus(
domainStatus: Map[String, DomainStatus],
unreachableDomains: Map[String, NodeStatus.Failure],
participantStatus: Map[String, ParticipantStatus],
unreachableParticipants: Map[String, NodeStatus.Failure],
) extends CantonStatus {
def tupled: (Map[String, DomainStatus], Map[String, ParticipantStatus]) =
(domainStatus, participantStatus)
override def pretty: Pretty[CommunityCantonStatus] = prettyOfString { _ =>
val domains = descriptions(
domainStatus,
unreachableDomains,
DomainReference.InstanceType,
)
val participants =
descriptions(
participantStatus,
unreachableParticipants,
ParticipantReference.InstanceType,
)
(domains ++ participants).mkString(System.lineSeparator() * 2)
}
}

View File

@ -0,0 +1,52 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import com.digitalasset.canton.DomainAlias
import com.digitalasset.canton.participant.admin.{v0 as participantAdminV0}
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.topology.*
final case class ListConnectedDomainsResult(
domainAlias: DomainAlias,
domainId: DomainId,
healthy: Boolean,
)
object ListConnectedDomainsResult {
def fromProtoV0(
value: participantAdminV0.ListConnectedDomainsResponse.Result
): ParsingResult[ListConnectedDomainsResult] = {
val participantAdminV0.ListConnectedDomainsResponse.Result(domainAlias, domainId, healthy) =
value
for {
domainId <- DomainId.fromProtoPrimitive(domainId, "domainId")
domainAlias <- DomainAlias.fromProtoPrimitive(domainAlias)
} yield ListConnectedDomainsResult(
domainAlias = domainAlias,
domainId = domainId,
healthy = healthy,
)
}
}
final case class DarMetadata(
name: String,
main: String,
packages: Seq[String],
dependencies: Seq[String],
)
object DarMetadata {
def fromProtoV0(
value: participantAdminV0.ListDarContentsResponse
): ParsingResult[DarMetadata] = {
val participantAdminV0.ListDarContentsResponse(description, main, packages, dependencies) =
value
Right(DarMetadata(description, main, packages, dependencies))
}
}

View File

@ -0,0 +1,180 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import cats.syntax.either.*
import com.daml.nonempty.NonEmptyUtil
import com.digitalasset.canton.admin.api.client.data.crypto.*
import com.digitalasset.canton.config.RequireTypes.NonNegativeInt
import com.digitalasset.canton.config.{NonNegativeFiniteDuration, PositiveDurationSeconds}
import com.digitalasset.canton.protocol.DynamicDomainParameters.InvalidDynamicDomainParameters
import com.digitalasset.canton.protocol.{
DynamicDomainParameters as DynamicDomainParametersInternal,
StaticDomainParameters as StaticDomainParametersInternal,
v2 as protocolV2,
}
import com.digitalasset.canton.topology.admin.v0.DomainParametersChangeAuthorization
import com.digitalasset.canton.util.BinaryFileUtil
import com.digitalasset.canton.version.ProtocolVersion
import com.digitalasset.canton.{crypto as DomainCrypto}
import com.google.common.annotations.VisibleForTesting
import io.scalaland.chimney.dsl.*
import scala.Ordering.Implicits.*
final case class StaticDomainParameters(
uniqueContractKeys: Boolean,
requiredSigningKeySchemes: Set[SigningKeyScheme],
requiredEncryptionKeySchemes: Set[EncryptionKeyScheme],
requiredSymmetricKeySchemes: Set[SymmetricKeyScheme],
requiredHashAlgorithms: Set[HashAlgorithm],
requiredCryptoKeyFormats: Set[CryptoKeyFormat],
protocolVersion: ProtocolVersion,
) {
def writeToFile(outputFile: String): Unit =
BinaryFileUtil.writeByteStringToFile(outputFile, toInternal.toByteString)
private[canton] def toInternal: StaticDomainParametersInternal =
StaticDomainParametersInternal.create(
uniqueContractKeys = uniqueContractKeys,
requiredSigningKeySchemes = NonEmptyUtil.fromUnsafe(
requiredSigningKeySchemes.map(_.transformInto[DomainCrypto.SigningKeyScheme])
),
requiredEncryptionKeySchemes = NonEmptyUtil.fromUnsafe(
requiredEncryptionKeySchemes.map(_.transformInto[DomainCrypto.EncryptionKeyScheme])
),
requiredSymmetricKeySchemes = NonEmptyUtil.fromUnsafe(
requiredSymmetricKeySchemes.map(_.transformInto[DomainCrypto.SymmetricKeyScheme])
),
requiredHashAlgorithms = NonEmptyUtil.fromUnsafe(
requiredHashAlgorithms.map(_.transformInto[DomainCrypto.HashAlgorithm])
),
requiredCryptoKeyFormats = NonEmptyUtil.fromUnsafe(
requiredCryptoKeyFormats.map(_.transformInto[DomainCrypto.CryptoKeyFormat])
),
protocolVersion = protocolVersion,
)
}
object StaticDomainParameters {
def apply(
domain: StaticDomainParametersInternal
): StaticDomainParameters =
StaticDomainParameters(
uniqueContractKeys = domain.uniqueContractKeys,
requiredSigningKeySchemes =
domain.requiredSigningKeySchemes.forgetNE.map(_.transformInto[SigningKeyScheme]),
requiredEncryptionKeySchemes =
domain.requiredEncryptionKeySchemes.forgetNE.map(_.transformInto[EncryptionKeyScheme]),
requiredSymmetricKeySchemes =
domain.requiredSymmetricKeySchemes.forgetNE.map(_.transformInto[SymmetricKeyScheme]),
requiredHashAlgorithms =
domain.requiredHashAlgorithms.forgetNE.map(_.transformInto[HashAlgorithm]),
requiredCryptoKeyFormats =
domain.requiredCryptoKeyFormats.forgetNE.map(_.transformInto[CryptoKeyFormat]),
protocolVersion = domain.protocolVersion,
)
def tryReadFromFile(inputFile: String): StaticDomainParameters = {
val staticDomainParametersInternal = StaticDomainParametersInternal
.readFromFile(inputFile)
.valueOr(err =>
throw new IllegalArgumentException(
s"Reading static domain parameters from file $inputFile failed: $err"
)
)
StaticDomainParameters(staticDomainParametersInternal)
}
}
// TODO(#15650) Properly expose new BFT parameters and domain limits
final case class DynamicDomainParameters(
participantResponseTimeout: NonNegativeFiniteDuration,
mediatorReactionTimeout: NonNegativeFiniteDuration,
transferExclusivityTimeout: NonNegativeFiniteDuration,
topologyChangeDelay: NonNegativeFiniteDuration,
ledgerTimeRecordTimeTolerance: NonNegativeFiniteDuration,
mediatorDeduplicationTimeout: NonNegativeFiniteDuration,
reconciliationInterval: PositiveDurationSeconds,
maxRatePerParticipant: NonNegativeInt,
maxRequestSize: NonNegativeInt,
sequencerAggregateSubmissionTimeout: NonNegativeFiniteDuration,
) {
if (ledgerTimeRecordTimeTolerance * 2 > mediatorDeduplicationTimeout)
throw new InvalidDynamicDomainParameters(
s"The ledgerTimeRecordTimeTolerance ($ledgerTimeRecordTimeTolerance) must be at most half of the " +
s"mediatorDeduplicationTimeout ($mediatorDeduplicationTimeout)."
)
// https://docs.google.com/document/d/1tpPbzv2s6bjbekVGBn6X5VZuw0oOTHek5c30CBo4UkI/edit#bookmark=id.1dzc6dxxlpca
private[canton] def compatibleWithNewLedgerTimeRecordTimeTolerance(
newLedgerTimeRecordTimeTolerance: NonNegativeFiniteDuration
): Boolean = {
// If false, a new request may receive the same ledger time as a previous request and the previous
// request may be evicted too early from the mediator's deduplication store.
// Thus, an attacker may assign the same UUID to both requests.
// See i9028 for a detailed design. (This is the second clause of item 2 of Lemma 2).
ledgerTimeRecordTimeTolerance + newLedgerTimeRecordTimeTolerance <= mediatorDeduplicationTimeout
}
def update(
participantResponseTimeout: NonNegativeFiniteDuration = participantResponseTimeout,
mediatorReactionTimeout: NonNegativeFiniteDuration = mediatorReactionTimeout,
transferExclusivityTimeout: NonNegativeFiniteDuration = transferExclusivityTimeout,
topologyChangeDelay: NonNegativeFiniteDuration = topologyChangeDelay,
ledgerTimeRecordTimeTolerance: NonNegativeFiniteDuration = ledgerTimeRecordTimeTolerance,
): DynamicDomainParameters = this.copy(
participantResponseTimeout = participantResponseTimeout,
mediatorReactionTimeout = mediatorReactionTimeout,
transferExclusivityTimeout = transferExclusivityTimeout,
topologyChangeDelay = topologyChangeDelay,
ledgerTimeRecordTimeTolerance = ledgerTimeRecordTimeTolerance,
)
def toProto: DomainParametersChangeAuthorization.Parameters =
DomainParametersChangeAuthorization.Parameters.ParametersV1(
protocolV2.DynamicDomainParameters(
participantResponseTimeout = Some(participantResponseTimeout.toProtoPrimitive),
mediatorReactionTimeout = Some(mediatorReactionTimeout.toProtoPrimitive),
transferExclusivityTimeout = Some(transferExclusivityTimeout.toProtoPrimitive),
topologyChangeDelay = Some(topologyChangeDelay.toProtoPrimitive),
ledgerTimeRecordTimeTolerance = Some(ledgerTimeRecordTimeTolerance.toProtoPrimitive),
mediatorDeduplicationTimeout = Some(mediatorDeduplicationTimeout.toProtoPrimitive),
reconciliationInterval = Some(reconciliationInterval.toProtoPrimitive),
defaultParticipantLimits = Some(
protocolV2.ParticipantDomainLimits(
maxRate = maxRatePerParticipant.unwrap,
maxNumParties = 0,
maxNumPackages = 0,
)
),
maxRequestSize = maxRequestSize.unwrap,
permissionedDomain = false,
requiredPackages = Nil,
onlyRequiredPackagesPermitted = false,
defaultMaxHostingParticipantsPerParty = 0,
sequencerAggregateSubmissionTimeout =
Some(sequencerAggregateSubmissionTimeout.toProtoPrimitive),
trafficControlParameters = None,
)
)
}
object DynamicDomainParameters {
/** Default dynamic domain parameters for non-static clocks */
@VisibleForTesting
def defaultValues(protocolVersion: ProtocolVersion): DynamicDomainParameters =
DynamicDomainParameters(
DynamicDomainParametersInternal.defaultValues(protocolVersion)
)
def apply(
domain: DynamicDomainParametersInternal
): DynamicDomainParameters =
domain.transformInto[DynamicDomainParameters]
}

View File

@ -0,0 +1,14 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
final case class LedgerApiObjectMeta(
resourceVersion: String,
annotations: Map[String, String],
)
object LedgerApiObjectMeta {
def empty: LedgerApiObjectMeta =
LedgerApiObjectMeta(resourceVersion = "", annotations = Map.empty)
}

View File

@ -0,0 +1,66 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import com.daml.ledger.api.v1.admin.metering_report_service.GetMeteringReportResponse
import com.digitalasset.canton.serialization.ProtoConverter
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.google.protobuf.struct
import com.google.protobuf.struct.Value.Kind
import com.google.protobuf.struct.{ListValue, Struct}
import io.circe.Decoder.Result
import io.circe.Json.*
import io.circe.*
object LedgerMeteringReport {
def fromProtoV0(
value: GetMeteringReportResponse
): ParsingResult[String] = {
for {
s <- ProtoConverter.required("meteringReportJson", value.meteringReportJson)
} yield {
StructEncoderDecoder(s).spaces2
}
}
}
object StructEncoderDecoder extends Encoder[struct.Struct] with Decoder[struct.Struct] {
override def apply(s: struct.Struct): Json = {
write(struct.Value.of(Kind.StructValue(s)))
}
override def apply(c: HCursor): Result[struct.Struct] = {
val value = read(c.value)
if (value.kind.isStructValue) Right(value.getStructValue)
else Left(DecodingFailure(s"Expected struct, not $value", Nil))
}
private def write(value: struct.Value): Json = {
value.kind match {
case Kind.BoolValue(v) => Json.fromBoolean(v)
case Kind.ListValue(v) => Json.fromValues(v.values.map(write))
case Kind.NumberValue(v) => Json.fromDoubleOrNull(v)
case Kind.StringValue(v) => Json.fromString(v)
case Kind.StructValue(v) => Json.fromFields(v.fields.view.mapValues(write))
case Kind.Empty | Kind.NullValue(_) => Json.Null
}
}
object StructFolder extends Folder[Kind] {
def onNull = Kind.NullValue(struct.NullValue.NULL_VALUE)
def onBoolean(value: Boolean) = Kind.BoolValue(value)
def onNumber(value: JsonNumber) = Kind.NumberValue(value.toDouble)
def onString(value: String) = Kind.StringValue(value)
def onArray(value: Vector[Json]) = Kind.ListValue(ListValue(value.map(read)))
def onObject(value: JsonObject) =
Kind.StructValue(Struct.of(value.toMap.view.mapValues(read).toMap))
}
private def read(c: Json): struct.Value = struct.Value.of(c.foldWith(StructFolder))
}

View File

@ -0,0 +1,49 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import com.daml.ledger.api.v1.admin.object_meta.ObjectMeta as ProtoObjectMeta
import com.daml.ledger.api.v1.admin.party_management_service.PartyDetails as ProtoPartyDetails
import com.digitalasset.canton.topology.PartyId
import scala.util.control.NoStackTrace
/** Represents a party details value exposed in the Canton console
*/
final case class PartyDetails(
party: PartyId,
displayName: String,
isLocal: Boolean,
annotations: Map[String, String],
identityProviderId: String,
)
object PartyDetails {
def fromProtoPartyDetails(details: ProtoPartyDetails): PartyDetails = PartyDetails(
party = PartyId.tryFromProtoPrimitive(details.party),
displayName = details.displayName,
isLocal = details.isLocal,
annotations = details.localMetadata.fold(Map.empty[String, String])(_.annotations),
identityProviderId = details.identityProviderId,
)
def toProtoPartyDetails(
details: PartyDetails,
resourceVersionO: Option[String],
): ProtoPartyDetails = ProtoPartyDetails(
party = details.party.toString,
displayName = details.displayName,
isLocal = details.isLocal,
localMetadata = Some(
ProtoObjectMeta(
resourceVersion = resourceVersionO.getOrElse(""),
annotations = details.annotations,
)
),
identityProviderId = details.identityProviderId,
)
}
final case class ModifyingNonModifiablePartyDetailsPropertiesError()
extends RuntimeException("MODIFYING_AN_UNMODIFIABLE_PARTY_DETAILS_PROPERTY_ERROR")
with NoStackTrace

View File

@ -0,0 +1,54 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import com.digitalasset.canton.pruning.admin.v0
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.{config, participant, scheduler}
final case class PruningSchedule(
cron: String,
maxDuration: config.PositiveDurationSeconds,
retention: config.PositiveDurationSeconds,
)
object PruningSchedule {
private[admin] def fromProtoV0(scheduleP: v0.PruningSchedule): ParsingResult[PruningSchedule] =
for {
maxDuration <- config.PositiveDurationSeconds.fromProtoPrimitiveO("max_duration")(
scheduleP.maxDuration
)
retention <- config.PositiveDurationSeconds.fromProtoPrimitiveO("retention")(
scheduleP.retention
)
} yield PruningSchedule(scheduleP.cron, maxDuration, retention)
private[data] def fromInternal(
internalSchedule: scheduler.PruningSchedule
): PruningSchedule =
PruningSchedule(
internalSchedule.cron.toProtoPrimitive,
config.PositiveDurationSeconds(internalSchedule.maxDuration.toScala),
config.PositiveDurationSeconds(internalSchedule.retention.toScala),
)
}
final case class ParticipantPruningSchedule(
schedule: PruningSchedule,
pruneInternallyOnly: Boolean,
)
object ParticipantPruningSchedule {
private[admin] def fromProtoV0(
participantSchedule: v0.ParticipantPruningSchedule
): ParsingResult[ParticipantPruningSchedule] =
for {
internalSchedule <- participant.scheduler.ParticipantPruningSchedule.fromProtoV0(
participantSchedule
)
} yield ParticipantPruningSchedule(
PruningSchedule.fromInternal(internalSchedule.schedule),
participantSchedule.pruneInternallyOnly,
)
}

View File

@ -0,0 +1,53 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import com.daml.ledger.api.v1.ValueOuterClass
import com.daml.ledger.api.v1.value.Identifier
import com.daml.ledger.javaapi
final case class TemplateId(
packageId: String,
moduleName: String,
entityName: String,
) {
def toIdentifier: Identifier = Identifier(
packageId = packageId,
moduleName = moduleName,
entityName = entityName,
)
def toJavaIdentifier: javaapi.data.Identifier = new javaapi.data.Identifier(
packageId,
moduleName,
entityName,
)
def isModuleEntity(moduleName: String, entityName: String) =
this.moduleName == moduleName && this.entityName == entityName
}
object TemplateId {
def fromIdentifier(identifier: Identifier): TemplateId = {
TemplateId(
packageId = identifier.packageId,
moduleName = identifier.moduleName,
entityName = identifier.entityName,
)
}
def templateIdsFromJava(identifiers: javaapi.data.Identifier*): Seq[TemplateId] = {
identifiers.map(fromJavaIdentifier)
}
def fromJavaProtoIdentifier(templateId: ValueOuterClass.Identifier): TemplateId = {
fromIdentifier(Identifier.fromJavaProto(templateId))
}
def fromJavaIdentifier(templateId: javaapi.data.Identifier): TemplateId = {
fromJavaProtoIdentifier(templateId.toProto)
}
}

View File

@ -0,0 +1,247 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import cats.syntax.traverse.*
import com.digitalasset.canton.ProtoDeserializationError
import com.digitalasset.canton.admin.api.client.data.ListPartiesResult.ParticipantDomains
import com.digitalasset.canton.crypto.*
import com.digitalasset.canton.protocol.{DynamicDomainParameters as DynamicDomainParametersInternal}
import com.digitalasset.canton.serialization.ProtoConverter
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.topology.admin.v0
import com.digitalasset.canton.topology.admin.v0.ListDomainParametersChangesResult.Result.Parameters
import com.digitalasset.canton.topology.transaction.*
import com.google.protobuf.ByteString
import java.time.Instant
final case class ListPartiesResult(party: PartyId, participants: Seq[ParticipantDomains])
object ListPartiesResult {
final case class DomainPermission(domain: DomainId, permission: ParticipantPermission)
final case class ParticipantDomains(participant: ParticipantId, domains: Seq[DomainPermission])
private def fromProtoV0(
value: v0.ListPartiesResponse.Result.ParticipantDomains.DomainPermissions
): ParsingResult[DomainPermission] =
for {
domainId <- DomainId.fromProtoPrimitive(value.domain, "domain")
permission <- ParticipantPermission.fromProtoEnum(value.permission)
} yield DomainPermission(domainId, permission)
private def fromProtoV0(
value: v0.ListPartiesResponse.Result.ParticipantDomains
): ParsingResult[ParticipantDomains] =
for {
participantId <- ParticipantId.fromProtoPrimitive(value.participant, "participant")
domains <- value.domains.traverse(fromProtoV0)
} yield ParticipantDomains(participantId, domains)
def fromProtoV0(
value: v0.ListPartiesResponse.Result
): ParsingResult[ListPartiesResult] =
for {
partyUid <- UniqueIdentifier.fromProtoPrimitive(value.party, "party")
participants <- value.participants.traverse(fromProtoV0)
} yield ListPartiesResult(PartyId(partyUid), participants)
}
final case class ListKeyOwnersResult(
store: DomainId,
owner: Member,
signingKeys: Seq[SigningPublicKey],
encryptionKeys: Seq[EncryptionPublicKey],
) {
def keys(purpose: KeyPurpose): Seq[PublicKey] = purpose match {
case KeyPurpose.Signing => signingKeys
case KeyPurpose.Encryption => encryptionKeys
}
}
object ListKeyOwnersResult {
def fromProtoV0(
value: v0.ListKeyOwnersResponse.Result
): ParsingResult[ListKeyOwnersResult] =
for {
domain <- DomainId.fromProtoPrimitive(value.domain, "domain")
owner <- Member.fromProtoPrimitive(value.keyOwner, "keyOwner")
signingKeys <- value.signingKeys.traverse(SigningPublicKey.fromProtoV0)
encryptionKeys <- value.encryptionKeys.traverse(EncryptionPublicKey.fromProtoV0)
} yield ListKeyOwnersResult(domain, owner, signingKeys, encryptionKeys)
}
final case class BaseResult(
domain: String,
validFrom: Instant,
validUntil: Option[Instant],
operation: TopologyChangeOp,
serialized: ByteString,
signedBy: Fingerprint,
)
object BaseResult {
def fromProtoV0(value: v0.BaseResult): ParsingResult[BaseResult] =
for {
protoValidFrom <- ProtoConverter.required("valid_from", value.validFrom)
validFrom <- ProtoConverter.InstantConverter.fromProtoPrimitive(protoValidFrom)
validUntil <- value.validUntil.traverse(ProtoConverter.InstantConverter.fromProtoPrimitive)
operation <- TopologyChangeOp.fromProtoV0(value.operation)
signedBy <- Fingerprint.fromProtoPrimitive(value.signedByFingerprint)
} yield BaseResult(value.store, validFrom, validUntil, operation, value.serialized, signedBy)
}
final case class ListPartyToParticipantResult(context: BaseResult, item: PartyToParticipant)
object ListPartyToParticipantResult {
def fromProtoV0(
value: v0.ListPartyToParticipantResult.Result
): ParsingResult[ListPartyToParticipantResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- PartyToParticipant.fromProtoV0(itemProto)
} yield ListPartyToParticipantResult(context, item)
}
final case class ListOwnerToKeyMappingResult(
context: BaseResult,
item: OwnerToKeyMapping,
key: Fingerprint,
)
object ListOwnerToKeyMappingResult {
def fromProtoV0(
value: v0.ListOwnerToKeyMappingResult.Result
): ParsingResult[ListOwnerToKeyMappingResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- OwnerToKeyMapping.fromProtoV0(itemProto)
key <- Fingerprint.fromProtoPrimitive(value.keyFingerprint)
} yield ListOwnerToKeyMappingResult(context, item, key)
}
final case class ListNamespaceDelegationResult(
context: BaseResult,
item: NamespaceDelegation,
targetKey: Fingerprint,
)
object ListNamespaceDelegationResult {
def fromProtoV0(
value: v0.ListNamespaceDelegationResult.Result
): ParsingResult[ListNamespaceDelegationResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- NamespaceDelegation.fromProtoV0(itemProto)
targetKey <- Fingerprint.fromProtoPrimitive(value.targetKeyFingerprint)
} yield ListNamespaceDelegationResult(context, item, targetKey)
}
final case class ListIdentifierDelegationResult(
context: BaseResult,
item: IdentifierDelegation,
targetKey: Fingerprint,
)
object ListIdentifierDelegationResult {
def fromProtoV0(
value: v0.ListIdentifierDelegationResult.Result
): ParsingResult[ListIdentifierDelegationResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- IdentifierDelegation.fromProtoV0(itemProto)
targetKey <- Fingerprint.fromProtoPrimitive(value.targetKeyFingerprint)
} yield ListIdentifierDelegationResult(context, item, targetKey)
}
final case class ListSignedLegalIdentityClaimResult(context: BaseResult, item: LegalIdentityClaim)
object ListSignedLegalIdentityClaimResult {
def fromProtoV0(
value: v0.ListSignedLegalIdentityClaimResult.Result
): ParsingResult[ListSignedLegalIdentityClaimResult] = {
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- SignedLegalIdentityClaim.fromProtoV0(itemProto)
claim <- LegalIdentityClaim.fromByteString(item.claim)
} yield ListSignedLegalIdentityClaimResult(context, claim)
}
}
final case class ListParticipantDomainStateResult(context: BaseResult, item: ParticipantState)
object ListParticipantDomainStateResult {
def fromProtoV0(
value: v0.ListParticipantDomainStateResult.Result
): ParsingResult[ListParticipantDomainStateResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- ParticipantState.fromProtoV0(itemProto)
} yield ListParticipantDomainStateResult(context, item)
}
final case class ListMediatorDomainStateResult(context: BaseResult, item: MediatorDomainState)
object ListMediatorDomainStateResult {
def fromProtoV0(
value: v0.ListMediatorDomainStateResult.Result
): ParsingResult[ListMediatorDomainStateResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- MediatorDomainState.fromProtoV0(itemProto)
} yield ListMediatorDomainStateResult(context, item)
}
final case class ListVettedPackagesResult(context: BaseResult, item: VettedPackages)
object ListVettedPackagesResult {
def fromProtoV0(
value: v0.ListVettedPackagesResult.Result
): ParsingResult[ListVettedPackagesResult] = {
val v0.ListVettedPackagesResult.Result(contextPO, itemPO) = value
for {
contextProto <- ProtoConverter.required("context", contextPO)
context <- BaseResult.fromProtoV0(contextProto)
itemProto <- ProtoConverter.required("item", itemPO)
item <- VettedPackages.fromProtoV0(itemProto)
} yield ListVettedPackagesResult(context, item)
}
}
final case class ListDomainParametersChangeResult(
context: BaseResult,
item: DynamicDomainParameters,
)
object ListDomainParametersChangeResult {
def fromProtoV0(
value: v0.ListDomainParametersChangesResult.Result
): ParsingResult[ListDomainParametersChangeResult] = for {
contextP <- value.context.toRight(ProtoDeserializationError.FieldNotSet("context"))
context <- BaseResult.fromProtoV0(contextP)
dynamicDomainParametersInternal <- value.parameters match {
case Parameters.Empty => Left(ProtoDeserializationError.FieldNotSet("parameters"))
case Parameters.V1(ddpX) => DynamicDomainParametersInternal.fromProtoV2(ddpX)
}
item = DynamicDomainParameters(dynamicDomainParametersInternal)
} yield ListDomainParametersChangeResult(context, item)
}

View File

@ -0,0 +1,125 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.daml.ledger.api.v1.admin.user_management_service.Right.Kind
import com.daml.ledger.api.v1.admin.user_management_service.{
ListUsersResponse as ProtoListUsersResponse,
Right as ProtoUserRight,
User as ProtoLedgerApiUser,
}
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.topology.PartyId
import com.digitalasset.canton.{LfPartyId, ProtoDeserializationError}
import scala.util.control.NoStackTrace
final case class LedgerApiUser(
id: String,
primaryParty: Option[PartyId],
isDeactivated: Boolean,
metadata: LedgerApiObjectMeta,
identityProviderId: String,
)
object LedgerApiUser {
def fromProtoV0(
value: ProtoLedgerApiUser
): ParsingResult[LedgerApiUser] = {
val ProtoLedgerApiUser(id, primaryParty, isDeactivated, metadataO, identityProviderId) = value
Option
.when(primaryParty.nonEmpty)(primaryParty)
.traverse(LfPartyId.fromString(_).flatMap(PartyId.fromLfParty(_)))
.leftMap { err =>
ProtoDeserializationError.ValueConversionError("primaryParty", err)
}
.map { primaryPartyO =>
LedgerApiUser(
id = id,
primaryParty = primaryPartyO,
isDeactivated = isDeactivated,
metadata = LedgerApiObjectMeta(
resourceVersion = metadataO.fold("")(_.resourceVersion),
annotations = metadataO.fold(Map.empty[String, String])(_.annotations),
),
identityProviderId = identityProviderId,
)
}
}
}
final case class UserRights(
actAs: Set[PartyId],
readAs: Set[PartyId],
participantAdmin: Boolean,
identityProviderAdmin: Boolean,
)
object UserRights {
def fromProtoV0(
values: Seq[ProtoUserRight]
): ParsingResult[UserRights] = {
Right(values.map(_.kind).foldLeft(UserRights(Set(), Set(), false, false)) {
case (acc, Kind.Empty) => acc
case (acc, Kind.ParticipantAdmin(value)) => acc.copy(participantAdmin = true)
case (acc, Kind.CanActAs(value)) =>
acc.copy(actAs = acc.actAs + PartyId.tryFromProtoPrimitive(value.party))
case (acc, Kind.CanReadAs(value)) =>
acc.copy(readAs = acc.readAs + PartyId.tryFromProtoPrimitive(value.party))
case (acc, Kind.IdentityProviderAdmin(value)) =>
acc.copy(identityProviderAdmin = true)
})
}
}
final case class ListLedgerApiUsersResult(users: Seq[LedgerApiUser], nextPageToken: String)
object ListLedgerApiUsersResult {
def fromProtoV0(
value: ProtoListUsersResponse,
filterUser: String,
): ParsingResult[ListLedgerApiUsersResult] = {
val ProtoListUsersResponse(protoUsers, nextPageToken) = value
protoUsers.traverse(LedgerApiUser.fromProtoV0).map { users =>
ListLedgerApiUsersResult(users.filter(_.id.startsWith(filterUser)), nextPageToken)
}
}
}
/** Represents a user value exposed in the Canton console
*/
final case class User(
id: String,
primaryParty: Option[PartyId],
isActive: Boolean,
annotations: Map[String, String],
identityProviderId: String,
)
object User {
def fromLapiUser(u: LedgerApiUser): User = User(
id = u.id,
primaryParty = u.primaryParty,
isActive = !u.isDeactivated,
annotations = u.metadata.annotations,
identityProviderId = u.identityProviderId,
)
def toLapiUser(u: User, resourceVersion: Option[String]): LedgerApiUser = LedgerApiUser(
id = u.id,
primaryParty = u.primaryParty,
isDeactivated = !u.isActive,
metadata = LedgerApiObjectMeta(
resourceVersion = resourceVersion.getOrElse(""),
annotations = u.annotations,
),
identityProviderId = u.identityProviderId,
)
}
final case class ModifyingNonModifiableUserPropertiesError()
extends RuntimeException("MODIFYING_AN_UNMODIFIABLE_USER_PROPERTY_ERROR")
with NoStackTrace
final case class UsersPage(users: Seq[User], nextPageToken: String)

View File

@ -0,0 +1,27 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data.crypto
sealed trait CryptoKeyFormat extends Product with Serializable {
def name: String
override def toString: String = name
}
object CryptoKeyFormat {
case object Tink extends CryptoKeyFormat {
override val name: String = "Tink"
}
case object Der extends CryptoKeyFormat {
override val name: String = "DER"
}
case object Raw extends CryptoKeyFormat {
override val name: String = "Raw"
}
case object Symbolic extends CryptoKeyFormat {
override val name: String = "Symbolic"
}
}

View File

@ -0,0 +1,41 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data.crypto
/** Key schemes for asymmetric/hybrid encryption. */
sealed trait EncryptionKeyScheme extends Product with Serializable {
def name: String
override def toString: String = name
}
object EncryptionKeyScheme {
case object EciesP256HkdfHmacSha256Aes128Gcm extends EncryptionKeyScheme {
override val name: String = "ECIES-P256_HMAC256_AES128-GCM"
}
case object EciesP256HmacSha256Aes128Cbc extends EncryptionKeyScheme {
override val name: String = "ECIES-P256_HMAC256_AES128-CBC"
}
case object Rsa2048OaepSha256 extends EncryptionKeyScheme {
override val name: String = "RSA2048-OAEP-SHA256"
}
}
/** Key schemes for symmetric encryption. */
sealed trait SymmetricKeyScheme extends Product with Serializable {
def name: String
override def toString: String = name
def keySizeInBytes: Int
}
object SymmetricKeyScheme {
/** AES with 128bit key in GCM */
case object Aes128Gcm extends SymmetricKeyScheme {
override def name: String = "AES128-GCM"
override def keySizeInBytes: Int = 16
}
}

View File

@ -0,0 +1,10 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data.crypto
sealed abstract class HashAlgorithm(val name: String)
object HashAlgorithm {
case object Sha256 extends HashAlgorithm("SHA-256")
}

View File

@ -0,0 +1,29 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data.crypto
sealed trait SigningKeyScheme extends Product with Serializable {
def name: String
override def toString: String = name
}
/** Schemes for signature keys.
*
* Ed25519 is the best performing curve and should be the default.
* EC-DSA is slower than Ed25519 but has better compatibility with other systems (such as CCF).
*/
object SigningKeyScheme {
case object Ed25519 extends SigningKeyScheme {
override val name: String = "Ed25519"
}
case object EcDsaP256 extends SigningKeyScheme {
override def name: String = "ECDSA-P256"
}
case object EcDsaP384 extends SigningKeyScheme {
override def name: String = "ECDSA-P384"
}
}

View File

@ -0,0 +1,325 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.admin.api.client.data.topologyx
import cats.syntax.either.*
import cats.syntax.traverse.*
import com.daml.nonempty.NonEmpty
import com.digitalasset.canton.ProtoDeserializationError.RefinedDurationConversionError
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.crypto.Fingerprint
import com.digitalasset.canton.protocol.DynamicDomainParameters
import com.digitalasset.canton.serialization.ProtoConverter
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.topology.admin.v1
import com.digitalasset.canton.topology.transaction.{
AuthorityOfX,
DomainTrustCertificateX,
IdentifierDelegationX,
MediatorDomainStateX,
NamespaceDelegationX,
OwnerToKeyMappingX,
ParticipantDomainPermissionX,
PartyHostingLimitsX,
PartyToParticipantX,
PurgeTopologyTransactionX,
SequencerDomainStateX,
TopologyChangeOpX,
TrafficControlStateX,
UnionspaceDefinitionX,
VettedPackagesX,
}
import com.google.protobuf.ByteString
import java.time.Instant
final case class BaseResult(
domain: String,
validFrom: Instant,
validUntil: Option[Instant],
operation: TopologyChangeOpX,
transactionHash: ByteString,
serial: PositiveInt,
signedBy: NonEmpty[Seq[Fingerprint]],
)
object BaseResult {
def fromProtoV1(value: v1.BaseResult): ParsingResult[BaseResult] =
for {
protoValidFrom <- ProtoConverter.required("valid_from", value.validFrom)
validFrom <- ProtoConverter.InstantConverter.fromProtoPrimitive(protoValidFrom)
validUntil <- value.validUntil.traverse(ProtoConverter.InstantConverter.fromProtoPrimitive)
operation <- TopologyChangeOpX.fromProtoV2(value.operation)
serial <- PositiveInt
.create(value.serial)
.leftMap(e => RefinedDurationConversionError("serial", e.message))
signedBy <-
ProtoConverter.parseRequiredNonEmpty(
Fingerprint.fromProtoPrimitive,
"signed_by_fingerprints",
value.signedByFingerprints,
)
} yield BaseResult(
value.store,
validFrom,
validUntil,
operation,
value.transactionHash,
serial,
signedBy,
)
}
final case class ListNamespaceDelegationResult(
context: BaseResult,
item: NamespaceDelegationX,
)
object ListNamespaceDelegationResult {
def fromProtoV1(
value: v1.ListNamespaceDelegationResult.Result
): ParsingResult[ListNamespaceDelegationResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- NamespaceDelegationX.fromProtoV2(itemProto)
} yield ListNamespaceDelegationResult(context, item)
}
final case class ListUnionspaceDefinitionResult(
context: BaseResult,
item: UnionspaceDefinitionX,
)
object ListUnionspaceDefinitionResult {
def fromProtoV1(
value: v1.ListUnionspaceDefinitionResult.Result
): ParsingResult[ListUnionspaceDefinitionResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- UnionspaceDefinitionX.fromProtoV2(itemProto)
} yield ListUnionspaceDefinitionResult(context, item)
}
final case class ListIdentifierDelegationResult(
context: BaseResult,
item: IdentifierDelegationX,
)
object ListIdentifierDelegationResult {
def fromProtoV1(
value: v1.ListIdentifierDelegationResult.Result
): ParsingResult[ListIdentifierDelegationResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- IdentifierDelegationX.fromProtoV2(itemProto)
} yield ListIdentifierDelegationResult(context, item)
}
final case class ListOwnerToKeyMappingResult(
context: BaseResult,
item: OwnerToKeyMappingX,
)
object ListOwnerToKeyMappingResult {
def fromProtoV1(
value: v1.ListOwnerToKeyMappingResult.Result
): ParsingResult[ListOwnerToKeyMappingResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- OwnerToKeyMappingX.fromProtoV2(itemProto)
} yield ListOwnerToKeyMappingResult(context, item)
}
final case class ListDomainTrustCertificateResult(
context: BaseResult,
item: DomainTrustCertificateX,
)
object ListDomainTrustCertificateResult {
def fromProtoV1(
value: v1.ListDomainTrustCertificateResult.Result
): ParsingResult[ListDomainTrustCertificateResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- DomainTrustCertificateX.fromProtoV2(itemProto)
} yield ListDomainTrustCertificateResult(context, item)
}
final case class ListParticipantDomainPermissionResult(
context: BaseResult,
item: ParticipantDomainPermissionX,
)
object ListParticipantDomainPermissionResult {
def fromProtoV1(
value: v1.ListParticipantDomainPermissionResult.Result
): ParsingResult[ListParticipantDomainPermissionResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- ParticipantDomainPermissionX.fromProtoV2(itemProto)
} yield ListParticipantDomainPermissionResult(context, item)
}
final case class ListPartyHostingLimitsResult(
context: BaseResult,
item: PartyHostingLimitsX,
)
object ListPartyHostingLimitsResult {
def fromProtoV1(
value: v1.ListPartyHostingLimitsResult.Result
): ParsingResult[ListPartyHostingLimitsResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- PartyHostingLimitsX.fromProtoV2(itemProto)
} yield ListPartyHostingLimitsResult(context, item)
}
final case class ListVettedPackagesResult(
context: BaseResult,
item: VettedPackagesX,
)
object ListVettedPackagesResult {
def fromProtoV1(
value: v1.ListVettedPackagesResult.Result
): ParsingResult[ListVettedPackagesResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- VettedPackagesX.fromProtoV2(itemProto)
} yield ListVettedPackagesResult(context, item)
}
final case class ListPartyToParticipantResult(
context: BaseResult,
item: PartyToParticipantX,
)
object ListPartyToParticipantResult {
def fromProtoV1(
value: v1.ListPartyToParticipantResult.Result
): ParsingResult[ListPartyToParticipantResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- PartyToParticipantX.fromProtoV2(itemProto)
} yield ListPartyToParticipantResult(context, item)
}
final case class ListAuthorityOfResult(
context: BaseResult,
item: AuthorityOfX,
)
object ListAuthorityOfResult {
def fromProtoV1(
value: v1.ListAuthorityOfResult.Result
): ParsingResult[ListAuthorityOfResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- AuthorityOfX.fromProtoV2(itemProto)
} yield ListAuthorityOfResult(context, item)
}
final case class ListDomainParametersStateResult(
context: BaseResult,
item: DynamicDomainParameters,
)
object ListDomainParametersStateResult {
def fromProtoV1(
value: v1.ListDomainParametersStateResult.Result
): ParsingResult[ListDomainParametersStateResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- DynamicDomainParameters.fromProtoV2(itemProto)
} yield ListDomainParametersStateResult(context, item)
}
final case class ListMediatorDomainStateResult(
context: BaseResult,
item: MediatorDomainStateX,
)
object ListMediatorDomainStateResult {
def fromProtoV1(
value: v1.ListMediatorDomainStateResult.Result
): ParsingResult[ListMediatorDomainStateResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- MediatorDomainStateX.fromProtoV2(itemProto)
} yield ListMediatorDomainStateResult(context, item)
}
final case class ListSequencerDomainStateResult(
context: BaseResult,
item: SequencerDomainStateX,
)
object ListSequencerDomainStateResult {
def fromProtoV1(
value: v1.ListSequencerDomainStateResult.Result
): ParsingResult[ListSequencerDomainStateResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- SequencerDomainStateX.fromProtoV2(itemProto)
} yield ListSequencerDomainStateResult(context, item)
}
final case class ListPurgeTopologyTransactionXResult(
context: BaseResult,
item: PurgeTopologyTransactionX,
)
object ListPurgeTopologyTransactionXResult {
def fromProtoV1(
value: v1.ListPurgeTopologyTransactionXResult.Result
): ParsingResult[ListPurgeTopologyTransactionXResult] =
for {
contextProto <- ProtoConverter.required("context", value.context)
context <- BaseResult.fromProtoV1(contextProto)
itemProto <- ProtoConverter.required("item", value.item)
item <- PurgeTopologyTransactionX.fromProtoV2(itemProto)
} yield ListPurgeTopologyTransactionXResult(context, item)
}
final case class ListTrafficStateResult(
context: BaseResult,
item: TrafficControlStateX,
)
object ListTrafficStateResult {
def fromProtoV1(
value: v1.ListTrafficStateResult.Result
): ParsingResult[ListTrafficStateResult] =
for {
context <- ProtoConverter.parseRequired(BaseResult.fromProtoV1, "context", value.context)
item <- ProtoConverter.parseRequired(TrafficControlStateX.fromProtoV2, "item", value.item)
} yield ListTrafficStateResult(context, item)
}

View File

@ -0,0 +1,140 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.config
import cats.data.Validated
import cats.syntax.functor.*
import com.daml.nonempty.NonEmpty
import com.digitalasset.canton.config.CantonRequireTypes.InstanceName
import com.digitalasset.canton.config.ConfigErrors.CantonConfigError
import com.digitalasset.canton.domain.config.{
CommunityDomainConfig,
DomainBaseConfig,
RemoteDomainConfig,
}
import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, TracedLogger}
import com.digitalasset.canton.participant.config.{
CommunityParticipantConfig,
LocalParticipantConfig,
RemoteParticipantConfig,
}
import com.digitalasset.canton.tracing.TraceContext
import com.typesafe.config.Config
import monocle.macros.syntax.lens.*
import org.slf4j.{Logger, LoggerFactory}
import pureconfig.{ConfigReader, ConfigWriter}
import java.io.File
import scala.annotation.nowarn
final case class CantonCommunityConfig(
domains: Map[InstanceName, CommunityDomainConfig] = Map.empty,
participants: Map[InstanceName, CommunityParticipantConfig] = Map.empty,
participantsX: Map[InstanceName, CommunityParticipantConfig] = Map.empty,
remoteDomains: Map[InstanceName, RemoteDomainConfig] = Map.empty,
remoteParticipants: Map[InstanceName, RemoteParticipantConfig] = Map.empty,
remoteParticipantsX: Map[InstanceName, RemoteParticipantConfig] = Map.empty,
monitoring: MonitoringConfig = MonitoringConfig(),
parameters: CantonParameters = CantonParameters(),
features: CantonFeatures = CantonFeatures(),
) extends CantonConfig
with ConfigDefaults[DefaultPorts, CantonCommunityConfig] {
override type DomainConfigType = CommunityDomainConfig
override type ParticipantConfigType = CommunityParticipantConfig
/** renders the config as json (used for dumping config for diagnostic purposes) */
override def dumpString: String = CantonCommunityConfig.makeConfidentialString(this)
override def validate: Validated[NonEmpty[Seq[String]], Unit] =
CommunityConfigValidations.validate(this)
override def withDefaults(ports: DefaultPorts): CantonCommunityConfig =
this
.focus(_.domains)
.modify(_.fmap(_.withDefaults(ports)))
.focus(_.participants)
.modify(_.fmap(_.withDefaults(ports)))
.focus(_.participantsX)
.modify(_.fmap(_.withDefaults(ports)))
}
@nowarn("cat=lint-byname-implicit") // https://github.com/scala/bug/issues/12072
object CantonCommunityConfig {
/** Combine together deprecated implicits for types that define them
* This setup allows the compiler to pick the implicit for the most specific type when applying deprecations.
* For instance,
* ConfigReader[LocalParticipantConfig].applyDeprecations will pick up the deprecations implicit defined in
* LocalParticipantConfig instead of LocalNodeConfig
* despite LocalParticipantConfig being a subtype of LocalNodeConfig.
*/
object CantonDeprecationImplicits
extends LocalNodeConfig.LocalNodeConfigDeprecationImplicits
with LocalParticipantConfig.LocalParticipantDeprecationsImplicits
with DomainBaseConfig.DomainBaseConfigDeprecationImplicits
private val logger: Logger = LoggerFactory.getLogger(classOf[CantonCommunityConfig])
private val elc = ErrorLoggingContext(
TracedLogger(logger),
NamedLoggerFactory.root.properties,
TraceContext.empty,
)
import pureconfig.generic.semiauto.*
import CantonConfig.*
// Implemented as a def so we can pass the ErrorLoggingContext to be used during parsing
@nowarn("cat=unused")
private implicit def cantonCommunityConfigReader(implicit
elc: ErrorLoggingContext
): ConfigReader[CantonCommunityConfig] = { // memoize it so we get the same instance every time
val configReaders: ConfigReaders = new ConfigReaders()
import configReaders.*
import DeprecatedConfigUtils.*
import CantonDeprecationImplicits.*
implicit val communityDomainConfigReader: ConfigReader[CommunityDomainConfig] =
deriveReader[CommunityDomainConfig].applyDeprecations
implicit val communityParticipantConfigReader: ConfigReader[CommunityParticipantConfig] =
deriveReader[CommunityParticipantConfig].applyDeprecations
deriveReader[CantonCommunityConfig]
}
@nowarn("cat=unused")
private lazy implicit val cantonCommunityConfigWriter: ConfigWriter[CantonCommunityConfig] = {
val writers = new CantonConfig.ConfigWriters(confidential = true)
import writers.*
implicit val communityDomainConfigWriter: ConfigWriter[CommunityDomainConfig] =
deriveWriter[CommunityDomainConfig]
implicit val communityParticipantConfigWriter: ConfigWriter[CommunityParticipantConfig] =
deriveWriter[CommunityParticipantConfig]
deriveWriter[CantonCommunityConfig]
}
def load(config: Config)(implicit
elc: ErrorLoggingContext = elc
): Either[CantonConfigError, CantonCommunityConfig] =
CantonConfig.loadAndValidate[CantonCommunityConfig](config)
def loadOrExit(config: Config)(implicit elc: ErrorLoggingContext = elc): CantonCommunityConfig =
CantonConfig.loadOrExit[CantonCommunityConfig](config)
def parseAndLoad(files: Seq[File])(implicit
elc: ErrorLoggingContext = elc
): Either[CantonConfigError, CantonCommunityConfig] =
CantonConfig.parseAndLoad[CantonCommunityConfig](files)
def parseAndLoadOrExit(files: Seq[File])(implicit
elc: ErrorLoggingContext = elc
): CantonCommunityConfig =
CantonConfig.parseAndLoadOrExit[CantonCommunityConfig](files)
def makeConfidentialString(config: CantonCommunityConfig): String =
"canton " + ConfigWriter[CantonCommunityConfig]
.to(config)
.render(CantonConfig.defaultConfigRenderer)
}

View File

@ -0,0 +1,302 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.config
import cats.data.Validated
import cats.syntax.foldable.*
import cats.syntax.functor.*
import cats.syntax.functorFilter.*
import com.daml.nonempty.NonEmpty
import com.daml.nonempty.catsinstances.*
import com.digitalasset.canton.config.CantonRequireTypes.InstanceName
import com.digitalasset.canton.domain.config.DomainParametersConfig
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.tracing.TraceContext
import com.digitalasset.canton.version.HandshakeErrors.DeprecatedProtocolVersion
import com.digitalasset.canton.version.ProtocolVersion
import java.net.URI
private[config] trait ConfigValidations[C <: CantonConfig] {
final def validate(config: C): Validated[NonEmpty[Seq[String]], Unit] =
validations.traverse_(_(config))
protected val validations: List[C => Validated[NonEmpty[Seq[String]], Unit]]
}
object CommunityConfigValidations
extends ConfigValidations[CantonCommunityConfig]
with NamedLogging {
import TraceContext.Implicits.Empty.*
override protected def loggerFactory: NamedLoggerFactory = NamedLoggerFactory.root
final case class DbAccess(url: String, user: Option[String]) {
private lazy val urlNoPassword = {
val uri = new URI(
url.replace("jdbc:", "")
)
val queryNoPassword = Option(uri.getQuery)
.getOrElse("")
.split('&')
.map(param =>
if (param.startsWith("password=")) ""
else param
)
.mkString
new URI(uri.getScheme, uri.getAuthority, uri.getPath, queryNoPassword, uri.getFragment)
}
override def toString: String =
s"DbAccess($urlNoPassword, $user)"
}
type Validation = CantonCommunityConfig => Validated[NonEmpty[Seq[String]], Unit]
override protected val validations: List[Validation] =
List[Validation](noDuplicateStorage, atLeastOneNode) ++ genericValidations[
CantonCommunityConfig
]
/** Validations applied to all community and enterprise Canton configurations. */
private[config] def genericValidations[C <: CantonConfig]
: List[C => Validated[NonEmpty[Seq[String]], Unit]] =
List(
developmentProtocolSafetyCheckDomains,
developmentProtocolSafetyCheckParticipants,
warnIfUnsafeMinProtocolVersion,
warnIfUnsafeProtocolVersionEmbeddedDomain,
adminTokenSafetyCheckParticipants,
)
/** Group node configs by db access to find matching db storage configs.
* Overcomplicated types used are to work around that at this point nodes could have conflicting names so we can't just
* throw them all in a single map.
*/
private[config] def extractNormalizedDbAccess[C <: CantonConfig](
nodeConfigs: Map[String, LocalNodeConfig]*
): Map[DbAccess, List[(String, LocalNodeConfig)]] = {
// Basic attempt to normalize JDBC URL-based configuration and explicit property configuration
// Limitations: Does not parse nor normalize the JDBC URLs
def normalize(dbConfig: DbConfig): Option[DbAccess] = {
import slick.util.ConfigExtensionMethods.*
val slickConfig = dbConfig.config
def getPropStr(prop: String): Option[String] =
slickConfig.getStringOpt(prop).orElse(slickConfig.getStringOpt(s"properties.$prop"))
def getPropInt(prop: String): Option[Int] =
slickConfig.getIntOpt(prop).orElse(slickConfig.getIntOpt(s"properties.$prop"))
def extractUrl: Option[String] =
getPropStr("url").orElse(getPropStr("jdbcUrl"))
def extractServerPortDbAsUrl: Option[String] =
for {
server <- getPropStr("serverName")
port <- getPropInt("portNumber")
dbName <- getPropStr("databaseName")
url = dbConfig match {
case _: H2DbConfig => DbConfig.h2Url(dbName)
case _: PostgresDbConfig => DbConfig.postgresUrl(server, port, dbName)
// Assume Oracle
case _ => DbConfig.oracleUrl(server, port, dbName)
}
} yield url
val user = getPropStr("user")
extractUrl.orElse(extractServerPortDbAsUrl).map(url => DbAccess(url = url, user = user))
}
// combine into a single list of name to config
val configs = nodeConfigs.map(_.toList).foldLeft(List[(String, LocalNodeConfig)]())(_ ++ _)
val withStorageConfigs = configs.mapFilter { case (name, config) =>
config.storage match {
case dbConfig: DbConfig => normalize(dbConfig).map((_, name, config))
case _ => None
}
}
withStorageConfigs
.groupBy { case (dbAccess, _, _) => dbAccess }
.fmap(_.map { case (_, name, config) =>
(name, config)
})
}
private[config] def formatNodeList(nodes: List[(String, LocalNodeConfig)]): String =
nodes.map { case (name, config) => s"${config.nodeTypeName} $name" }.mkString(",")
/** Validate the config that the storage configuration is not shared between nodes. */
private def noDuplicateStorage(
config: CantonCommunityConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
val dbAccessToNodes =
extractNormalizedDbAccess(config.participantsByString, config.domainsByString)
dbAccessToNodes.toSeq
.traverse_ {
case (dbAccess, nodes) if nodes.lengthCompare(1) > 0 =>
Validated.invalid(
NonEmpty(Seq, s"Nodes ${formatNodeList(nodes)} share same DB access: $dbAccess")
)
case _ => Validated.valid(())
}
}
@SuppressWarnings(Array("org.wartremover.warts.Product", "org.wartremover.warts.Serializable"))
private def atLeastOneNode(
config: CantonCommunityConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
val CantonCommunityConfig(
domains,
participants,
participantsX,
remoteDomains,
remoteParticipants,
remoteParticipantsX,
_,
_,
_,
) =
config
Validated.cond(
Seq(
domains,
participants,
remoteDomains,
remoteParticipants,
participantsX,
remoteParticipantsX,
)
.exists(_.nonEmpty),
(),
NonEmpty(Seq, "At least one node must be defined in the configuration"),
)
}
private[config] val backwardsCompatibleLoggingConfigErr =
"Inconsistent configuration of canton.monitoring.log-message-payloads and canton.monitoring.logging.api.message-payloads. Please use the latter in your configuration"
private def developmentProtocolSafetyCheckDomains(
config: CantonConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
developmentProtocolSafetyCheck(
config.parameters.nonStandardConfig,
config.domains.toSeq.map { case (k, v) =>
(k, v.init.domainParameters)
},
)
}
private def developmentProtocolSafetyCheckParticipants(
config: CantonConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
def toNe(
name: String,
nonStandardConfig: Boolean,
devVersionSupport: Boolean,
): Validated[NonEmpty[Seq[String]], Unit] = {
Validated.cond(
nonStandardConfig || !devVersionSupport,
(),
NonEmpty(
Seq,
s"Enabling dev-version-support for participant $name requires you to explicitly set canton.parameters.non-standard-config = yes",
),
)
}
config.participants.toList.traverse_ { case (name, participantConfig) =>
toNe(
name.unwrap,
config.parameters.nonStandardConfig,
participantConfig.parameters.devVersionSupport,
)
}
}
private def warnIfUnsafeMinProtocolVersion(
config: CantonConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
config.participants.toSeq.foreach { case (name, config) =>
val minimum = config.parameters.minimumProtocolVersion.map(_.unwrap)
val isMinimumDeprecatedVersion = minimum.getOrElse(ProtocolVersion.minimum).isDeprecated
if (isMinimumDeprecatedVersion && !config.parameters.dontWarnOnDeprecatedPV)
DeprecatedProtocolVersion.WarnParticipant(name, minimum).discard
}
Validated.valid(())
}
private def warnIfUnsafeProtocolVersionEmbeddedDomain(
config: CantonConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
config.domains.toSeq.foreach { case (name, config) =>
val pv = config.init.domainParameters.protocolVersion.unwrap
if (pv.isDeprecated && !config.init.domainParameters.dontWarnOnDeprecatedPV)
DeprecatedProtocolVersion.WarnDomain(name, pv).discard
}
Validated.valid(())
}
private[config] def developmentProtocolSafetyCheck(
allowUnstableProtocolVersion: Boolean,
namesAndConfig: Seq[(InstanceName, DomainParametersConfig)],
): Validated[NonEmpty[Seq[String]], Unit] = {
def toNe(
name: String,
protocolVersion: ProtocolVersion,
allowUnstableProtocolVersion: Boolean,
): Validated[NonEmpty[Seq[String]], Unit] = {
Validated.cond(
protocolVersion.isStable || allowUnstableProtocolVersion,
(),
NonEmpty(
Seq,
s"Using non-stable protocol $protocolVersion for node $name requires you to explicitly set canton.parameters.non-standard-config = yes",
),
)
}
namesAndConfig.toList.traverse_ { case (name, parameters) =>
toNe(
name.unwrap,
parameters.protocolVersion.version,
allowUnstableProtocolVersion,
)
}
}
private def adminTokenSafetyCheckParticipants(
config: CantonConfig
): Validated[NonEmpty[Seq[String]], Unit] = {
def toNe(
name: String,
nonStandardConfig: Boolean,
adminToken: Option[String],
): Validated[NonEmpty[Seq[String]], Unit] = {
Validated.cond(
nonStandardConfig || adminToken.isEmpty,
(),
NonEmpty(
Seq,
s"Setting ledger-api.admin-token for participant $name requires you to explicitly set canton.parameters.non-standard-config = yes",
),
)
}
config.participants.toList.traverse_ { case (name, participantConfig) =>
toNe(
name.unwrap,
config.parameters.nonStandardConfig,
participantConfig.ledgerApi.adminToken,
)
}
}
}

View File

@ -0,0 +1,137 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.config
import com.daml.error.{ErrorCategory, ErrorCode, Explanation, Resolution}
import com.digitalasset.canton.error.CantonError
import com.digitalasset.canton.error.CantonErrorGroups.ConfigErrorGroup
import com.digitalasset.canton.logging.ErrorLoggingContext
import com.typesafe.config.ConfigException
import org.slf4j.event.Level
import pureconfig.error.ConfigReaderFailures
import java.io.File
import scala.collection.mutable
import scala.reflect.{ClassTag, classTag}
/** * Trait which acts as a wrapper around
* 1. `lightbend ConfigException`s which are caught when attempting to read or parse a configuration file
* 2. `pureconfig ConfigReaderFailures` which are returned when attempting to convert a given
* [[com.typesafe.config.Config]] instance (basically a valid HOCON-file)
* to one of the Canton configs
*/
object ConfigErrors extends ConfigErrorGroup {
sealed abstract class ConfigErrorCode(id: String)
extends ErrorCode(id, ErrorCategory.InvalidIndependentOfSystemState) {
// we classify ConfigErrors as ERROR so they are shown directly to the user when he attempts to start Canton
// via canton -c <config-file>
override def logLevel: Level = Level.ERROR
override def errorConveyanceDocString: Option[String] =
Some(
"Config errors are logged and output to stdout if starting Canton with a given configuration fails"
)
}
abstract class CantonConfigError(
override val cause: String,
override val throwableO: Option[Throwable] = None,
)(implicit override val code: ErrorCode)
extends CantonError {}
sealed abstract class ExceptionBasedConfigError(
override val cause: String,
override val throwableO: Option[Throwable] = None,
)(implicit override val code: ErrorCode)
extends CantonConfigError(cause, throwableO)(code) {
def exceptions: Seq[ConfigException]
override def log(): Unit = {
super.log()
exceptions.foreach { e =>
loggingContext.logger.debug(
code.toMsg(
s"Received the following exception while attempting to parse the Canton config files",
loggingContext.traceContext.traceId,
),
e,
)(loggingContext.traceContext)
}
}
}
final case object NoConfigFiles extends ConfigErrorCode("NO_CONFIG_FILES") {
final case class Error()(implicit override val loggingContext: ErrorLoggingContext)
extends CantonConfigError(
"No config files were given to Canton. We require at least one config file given via --config or a key:value pair given via -C."
)
}
@Resolution(""" In general, this can be one of many errors since this is the 'miscellaneous category' of configuration errors.
| One of the more common errors in this category is an 'unknown key' error. This error usually means that
| a keyword that is not valid (e.g. it may have a typo 'bort' instead of 'port'), or that a valid keyword
| at the wrong part of the configuration hierarchy was used (e.g. to enable database replication for a participant, the correct configuration
| is `canton.participants.participant2.replication.enabled = true` and not `canton.participants.replication.enabled = true`).
| Please refer to the scaladoc of either `CantonEnterpriseConfig` or `CantonCommunityConfig` (depending on whether the community or enterprise version is used) to find the valid configuration keywords and the correct position in the configuration hierarchy.
|""")
final case object GenericConfigError extends ConfigErrorCode("GENERIC_CONFIG_ERROR") {
final case class Error(override val cause: String)(implicit
override val loggingContext: ErrorLoggingContext
) extends CantonConfigError(cause)
}
@Explanation(
"This error is usually thrown when Canton can't find a given configuration file."
)
@Resolution(
"Make sure that the path and name of all configuration files is correctly specified. "
)
final case object CannotReadFilesError extends ConfigErrorCode("CANNOT_READ_CONFIG_FILES") {
final case class Error(unreadableFiles: Seq[File])(implicit
override val loggingContext: ErrorLoggingContext
) extends CantonConfigError("At least one configuration file could not be read.")
}
@Explanation(
"This error is usually thrown because a config file doesn't contain configs in valid HOCON format. " +
"The most common cause of an invalid HOCON format is a forgotten bracket."
)
@Resolution("Make sure that all files are in valid HOCON format.")
final case object CannotParseFilesError extends ConfigErrorCode("CANNOT_PARSE_CONFIG_FILES") {
final case class Error(override val exceptions: Seq[ConfigException])(implicit
override val loggingContext: ErrorLoggingContext
) extends ExceptionBasedConfigError(
s"Received an exception (full stack trace has been logged at DEBUG level) while attempting to parse ${exceptions.length} .conf-file(s)."
)
}
@Resolution(
"A common cause of this error is attempting to use an environment variable that isn't defined within a config-file. "
)
final case object SubstitutionError extends ConfigErrorCode("CONFIG_SUBSTITUTION_ERROR") {
final case class Error(override val exceptions: Seq[ConfigException])(implicit
override val loggingContext: ErrorLoggingContext
) extends ExceptionBasedConfigError(
s"Received an exception (full stack trace has been logged at DEBUG level) while attempting to parse ${exceptions.length} .conf-file(s)."
)
}
final case object ValidationError extends ConfigErrorCode("CONFIG_VALIDATION_ERROR") {
final case class Error(causes: Seq[String])(implicit
override val loggingContext: ErrorLoggingContext
) extends CantonConfigError(
s"Failed to validate the configuration due to: ${causes.mkString("\n")}"
)
}
def getMessage[ConfClass: ClassTag](failures: ConfigReaderFailures): String = {
val linesBuffer = mutable.Buffer.empty[String]
linesBuffer += s"Cannot convert configuration to a config of ${classTag[ConfClass].runtimeClass}. Failures are:"
linesBuffer += failures.prettyPrint(1)
linesBuffer += ""
linesBuffer.mkString(System.lineSeparator())
}
}

View File

@ -0,0 +1,118 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand
import com.digitalasset.canton.concurrent.Threading
import com.digitalasset.canton.config.{CantonConfig, NonNegativeDuration}
import com.digitalasset.canton.console.CommandErrors.ConsoleTimeout
import com.digitalasset.canton.crypto.Crypto
import com.digitalasset.canton.environment.{CantonNode, CantonNodeBootstrap}
import com.digitalasset.canton.logging.{NamedLogging, TracedLogger}
import scala.annotation.tailrec
/** Support for running an admin command
*/
trait AdminCommandRunner {
/** Run a GRPC admin command and return its result.
* Most of the commands are only defined for the GRPC interface, so we default to showing an error message
* if the command is called for a node configured with an HTTP interface.
*/
protected[console] def adminCommand[Result](
grpcCommand: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result]
protected[console] def tracedLogger: TracedLogger
}
object AdminCommandRunner {
def retryUntilTrue(timeout: NonNegativeDuration)(
condition: => Boolean
): ConsoleCommandResult[Unit] = {
val deadline = timeout.asFiniteApproximation.fromNow
@tailrec
def go(): ConsoleCommandResult[Unit] = {
val res = condition
if (!res) {
if (deadline.hasTimeLeft()) {
Threading.sleep(100)
go()
} else {
ConsoleTimeout.Error(timeout.asJavaApproximation)
}
} else {
CommandSuccessful(())
}
}
go()
}
}
/** Support for running ledgerApi commands
*/
trait LedgerApiCommandRunner {
protected[console] def ledgerApiCommand[Result](
command: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result]
protected[console] def token: Option[String]
}
/** Support for inspecting the instance */
trait BaseInspection[I <: CantonNode] {
def underlying: Option[I] = {
runningNode.flatMap(_.getNode)
}
protected[console] def runningNode: Option[CantonNodeBootstrap[I]]
protected[console] def startingNode: Option[CantonNodeBootstrap[I]]
protected[console] def name: String
protected[console] def access[T](ops: I => T): T = {
ops(
runningNode
.getOrElse(throw new IllegalArgumentException(s"instance $name is not running"))
.getNode
.getOrElse(
throw new IllegalArgumentException(
s"instance $name is still starting or awaiting manual initialisation."
)
)
)
}
protected[canton] def crypto: Crypto = {
runningNode
.flatMap(_.crypto)
.getOrElse(throw new IllegalArgumentException(s"instance $name is not running."))
}
}
trait FeatureFlagFilter extends NamedLogging {
protected def consoleEnvironment: ConsoleEnvironment
protected def cantonConfig: CantonConfig = consoleEnvironment.environment.config
private def checkEnabled[T](flag: Boolean, config: String, command: => T): T =
if (flag) {
command
} else {
noTracingLogger.error(
s"The command is currently disabled. You need to enable it explicitly by setting `canton.features.${config} = yes` in your Canton configuration file (`.conf`)"
)
throw new CommandFailure()
}
protected def check[T](flag: FeatureFlag)(command: => T): T =
checkEnabled(consoleEnvironment.featureSet.contains(flag), flag.configName, command)
}

View File

@ -0,0 +1,135 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import ammonite.runtime.Storage
import cats.syntax.either.*
import com.digitalasset.canton.logging.TracedLogger
import com.digitalasset.canton.tracing.TraceContext
import java.io.{File, RandomAccessFile}
import java.nio.channels.OverlappingFileLockException
import scala.concurrent.blocking
import scala.util.control.NonFatal
trait AmmoniteCacheLock {
def release(): Unit
def storage: Storage
def lockFile: Option[java.io.File]
}
object AmmoniteCacheLock {
// Don't change this to lazy val, as the underlying InMemory storage is not thread safe.
def InMemory: AmmoniteCacheLock = new AmmoniteCacheLock {
override def release(): Unit = ()
override val storage: Storage = Storage.InMemory()
override def toString: String = "in-memory cache"
override def lockFile: Option[File] = None
}
def create(logger: TracedLogger, path: os.Path, isRepl: Boolean): AmmoniteCacheLock = {
import TraceContext.Implicits.Empty.*
def go(index: Int): Either[String, AmmoniteCacheLock] = {
val newPath = path / s"$index"
for {
_ <- Either.cond(index < 255, (), s"Cache dir attempt reached $index, giving up")
_ <- createDirsIfNecessary(newPath)
_ <- ensureDirIsWritable(newPath)
lockO <- acquireLock(logger, newPath, isRepl).leftMap { err =>
logger.debug("Observed lock exception", err)
err.getMessage
}
lock <- lockO match {
case Some(value) => Right(value)
case None => go(index + 1)
}
} yield lock
}
try {
// check that cache directory is writable
val attempt = for {
_ <- createDirsIfNecessary(path)
_ <- ensureDirIsWritable(path)
lock <- go(0)
} yield lock
attempt match {
case Right(lock) => lock
case Left(err) =>
logger.warn(
s"Failed to acquire ammonite cache directory due to ${err}. Will use in-memory instead."
)
InMemory
}
} catch {
case NonFatal(e) =>
logger.warn("Failed to acquire ammonite cache directory. Will use in-memory instead.", e)
InMemory
}
}
private def createDirsIfNecessary(path: os.Path): Either[String, Unit] =
if (path.toIO.exists())
Either.cond(path.toIO.isDirectory, (), s"File ${path} exists but is not a directory")
else
Either.cond(
// create or test again (mkdirs fails if the directory exists in the meantime, which can happen
// if several tests try to create the directory at the same time
path.toIO.mkdirs() || path.toIO.exists(),
(),
s"Failed to create ammonite cache directory ${path}. Is the path writable?",
)
private def ensureDirIsWritable(path: os.Path): Either[String, Unit] = {
Either.cond(path.toIO.canWrite, (), s"Directory $path is not writable")
}
private def acquireLock(logger: TracedLogger, path: os.Path, isRepl: Boolean)(implicit
traceContext: TraceContext
): Either[Throwable, Option[AmmoniteCacheLock]] = blocking(synchronized {
try {
val myLockFile = path / "lock"
if (myLockFile.toIO.exists()) {
Right(None)
} else {
logger.debug(s"Attempting to obtain lock ${myLockFile}")
val out = new RandomAccessFile(myLockFile.toIO, "rw")
Option(out.getChannel.tryLock()) match {
case None =>
logger.debug(s"Failed to acquire lock for ${myLockFile}")
out.close()
Right(None)
case Some(lock) =>
myLockFile.toIO.deleteOnExit()
Right(Some(new AmmoniteCacheLock {
override def release(): Unit = {
try {
logger.debug(s"Releasing lock $myLockFile...")
lock.release()
out.close()
if (!myLockFile.toIO.delete()) {
logger.warn(s"Failed to delete lock file ${myLockFile}")
}
} catch {
case NonFatal(e) =>
logger.error(s"Releasing ammonite cache lock $lockFile failed", e)
}
}
override val storage: Storage = new Storage.Folder(path, isRepl = isRepl)
override def toString: String = s"file cache at $path"
override def lockFile: Option[File] = Some(myLockFile.toIO)
}))
}
}
} catch {
case e: OverlappingFileLockException => Right(None)
case NonFatal(e) => Left(e)
}
})
}

View File

@ -0,0 +1,91 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import ammonite.Main
import ammonite.main.Defaults
import ammonite.util.Colors
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.logging.TracedLogger
import com.typesafe.scalalogging.LazyLogging
import java.io.{File, IOException}
/** Configure behaviour of ammonite
*
* @param cacheDir cache dir, defaults to ~/.ammonite. If none is given, in-memory is used.
* If you specify a cache dir, the Canton repl will startup faster.
* In our tests, we have very rarely observed unexpected compile errors when the cache was enabled;
* if you want to avoid that, set the cache dir to None (i.e. `cache-dir = null` in the config file).
* @param workingDir working directory. if none is given, we'll use the working directory of the Canton process
* @param colors if true (default), we'll use color output
* @param verbose if true (not default), we'll emit verbose ammonite output
* @param defaultLimit default limit parameter for commands that can potentially return many results
*/
final case class AmmoniteConsoleConfig(
cacheDir: Option[java.io.File] = AmmoniteConsoleConfig.defaultCacheDir,
workingDir: Option[java.io.File] = None,
colors: Boolean = true,
verbose: Boolean = false,
defaultLimit: PositiveInt = PositiveInt.tryCreate(1000),
)
object AmmoniteConsoleConfig extends LazyLogging {
private def defaultCacheDir: Option[java.io.File] = {
val f = new File(System.getProperty("user.home"))
if (f.exists() && f.isDirectory)
Some(Defaults.ammoniteHome.toIO)
else {
logger.warn(
s"""Can not determine user home directory using the java system property `user.home`
| (is set to ${System.getProperty("user.home")}). Please set it
|on jvm startup using -Duser.home=...""".stripMargin
)
None
}
}
private def ensureTmpFilesCanBeCreated(): Unit = {
try {
val f = File.createTempFile("dummy", "test")
val _ = f.delete()
} catch {
case e: IOException =>
logger.error(
"Unable to create temporary files (javas `File.createTempFile` throws an exception). Please make sure that the jvm can create files. The process will likely start to fail now.",
e,
)
}
}
private[console] def create(
config: AmmoniteConsoleConfig,
predefCode: String,
welcomeBanner: Option[String],
isRepl: Boolean,
logger: TracedLogger,
): (AmmoniteCacheLock, Main) = {
val cacheLock: AmmoniteCacheLock = config.cacheDir match {
case Some(file) => AmmoniteCacheLock.create(logger, os.Path(file), isRepl = isRepl)
case None => AmmoniteCacheLock.InMemory
}
// ensure that we can create tmp files
ensureTmpFilesCanBeCreated()
val main = Main(
predefCode = predefCode,
storageBackend = cacheLock.storage,
wd = config.workingDir.fold(os.pwd)(x => os.Path(x.getAbsolutePath)),
welcomeBanner = welcomeBanner,
verboseOutput =
config.verbose, // disable things like "Compiling [x]..." messages from ammonite
// ammonite when run as a binary will log the number of commands that are executed in a session for the maintainer to see usage
// I don't think this happens when used in an embedded fashion like we're doing, but let's disable just to be sure ( ˇˇ )
remoteLogging = false,
colors = if (config.colors) Colors.Default else Colors.BlackWhite,
)
(cacheLock, main)
}
}

View File

@ -0,0 +1,8 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
/** Thrown when the bootstrap script fails to execute */
class BootstrapScriptException(cause: Throwable)
extends RuntimeException(s"Bootstrap script failed: ${cause.getMessage}", cause)

View File

@ -0,0 +1,195 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import better.files.File
import cats.data.NonEmptyList
import cats.syntax.parallel.*
import cats.syntax.traverse.*
import com.codahale.metrics
import com.digitalasset.canton.admin.api.client.data.{CantonStatus, CommunityCantonStatus}
import com.digitalasset.canton.config.RequireTypes.Port
import com.digitalasset.canton.config.{NonNegativeDuration, Password}
import com.digitalasset.canton.health.admin.data.NodeStatus
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.topology.DomainId
import com.digitalasset.canton.tracing.NoTracing
import com.digitalasset.canton.util.FutureInstances.*
import io.circe.{Encoder, Json, KeyEncoder, jawn}
import io.opentelemetry.exporter.internal.otlp.metrics.ResourceMetricsMarshaler
import io.opentelemetry.sdk.metrics.data.MetricData
import java.io.ByteArrayOutputStream
import java.time.Instant
import scala.concurrent.duration.TimeUnit
import scala.concurrent.{Await, ExecutionContext, Future, TimeoutException}
import scala.jdk.CollectionConverters.SeqHasAsJava
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}
object CantonHealthAdministrationEncoders {
implicit val timeUnitEncoder: Encoder[TimeUnit] = Encoder.encodeString.contramap(_.toString)
implicit val snapshotEncoder: Encoder[metrics.Snapshot] =
Encoder.forProduct4("mean", "std-dev", "p95", "median") { snapshot =>
def toMs(nanos: Double): Double = nanos / 1e6
(
toMs(snapshot.getMean),
toMs(snapshot.getStdDev),
toMs(snapshot.get95thPercentile()),
toMs(snapshot.getMedian),
)
}
implicit val counterEncoder: Encoder[metrics.Counter] = Encoder.forProduct1("count") { counter =>
counter.getCount
}
implicit val gaugeEncoder: Encoder[metrics.Gauge[_]] = Encoder.forProduct1("gauge") { gauge =>
gauge.getValue.toString
}
implicit val histoEncoder: Encoder[metrics.Histogram] =
Encoder.forProduct1("hist")(_.getSnapshot)
implicit val meterEncoder: Encoder[metrics.Meter] =
Encoder.forProduct3("count", "one-min-rate", "five-min-rate") { meter =>
(meter.getCount, meter.getFiveMinuteRate, meter.getOneMinuteRate)
}
implicit val timerEncoder: Encoder[metrics.Timer] =
Encoder.forProduct4("count", "one-min-rate", "five-min-rate", "hist") { timer =>
(timer.getCount, timer.getFiveMinuteRate, timer.getOneMinuteRate, timer.getSnapshot)
}
/** Wraps the standardized log writer from OpenTelemetry, that outputs the metrics as JSON
* Source: https://github.com/open-telemetry/opentelemetry-java/blob/main/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/OtlpJsonLoggingMetricExporter.java
* The encoder is not the most efficient as we first use the OpenTelemetry JSON serializer to write as a String,
* and then use the Circe Jawn decoder to transform the string into a circe.Json object.
* This is fine as the encoder is used only for on demand health dumps.
*/
implicit val openTelemetryMetricDataEncoder: Encoder[Seq[MetricData]] =
Encoder.encodeSeq[Json].contramap[Seq[MetricData]] { metrics =>
val resourceMetrics = ResourceMetricsMarshaler.create(metrics.asJava)
resourceMetrics.toSeq.map { resource =>
val byteArrayOutputStream = new ByteArrayOutputStream()
resource.writeJsonTo(byteArrayOutputStream)
jawn
.decode[Json](byteArrayOutputStream.toString)
.fold(
error => Json.fromString(s"Failed to decode metrics: $error"),
identity,
)
}
}
implicit val traceElemEncoder: Encoder[StackTraceElement] =
Encoder.encodeString.contramap(_.toString)
implicit val threadKeyEncoder: KeyEncoder[Thread] = (thread: Thread) => thread.getName
implicit val domainIdEncoder: KeyEncoder[DomainId] = (ref: DomainId) => ref.toString
implicit val encodePort: Encoder[Port] = Encoder.encodeInt.contramap[Port](_.unwrap)
// We do not want to serialize the password to JSON, e.g., as part of a config dump.
implicit val encoder: Encoder[Password] = Encoder.encodeString.contramap(_ => "****")
}
object CantonHealthAdministration {
def defaultHealthDumpName: File = {
// Replace ':' in the timestamp as they are forbidden on windows
val name = s"canton-dump-${Instant.now().toString.replace(':', '-')}.zip"
File(name)
}
}
trait CantonHealthAdministration[Status <: CantonStatus]
extends Helpful
with NamedLogging
with NoTracing {
protected val consoleEnv: ConsoleEnvironment
implicit private val ec: ExecutionContext = consoleEnv.environment.executionContext
override val loggerFactory: NamedLoggerFactory = consoleEnv.environment.loggerFactory
protected def statusMap[A <: InstanceReferenceCommon](
nodes: NodeReferences[A, _, _]
): Map[String, () => NodeStatus[A#Status]] = {
nodes.all.map { node => node.name -> (() => node.health.status) }.toMap
}
def status(): Status
@Help.Summary("Generate and write a health dump of Canton's state for a bug report")
@Help.Description(
"Gathers information about the current Canton process and/or remote nodes if using the console" +
" with a remote config. The outputFile argument can be used to write the health dump to a specific path." +
" The timeout argument can be increased when retrieving large health dumps from remote nodes." +
" The chunkSize argument controls the size of the byte chunks streamed back from remote nodes. This can be used" +
" if encountering errors due to gRPC max inbound message size being too low."
)
def dump(
outputFile: File = CantonHealthAdministration.defaultHealthDumpName,
timeout: NonNegativeDuration = consoleEnv.commandTimeouts.ledgerCommand,
chunkSize: Option[Int] = None,
): String = {
val remoteDumps = consoleEnv.nodes.remote.toList.parTraverse { n =>
Future {
n.health.dump(
File.newTemporaryFile(s"remote-${n.name}-"),
timeout,
chunkSize,
)
}
}
// Try to get a local dump by going through the local nodes and returning the first one that succeeds
def getLocalDump(nodes: NonEmptyList[InstanceReferenceCommon]): Future[String] = {
Future {
nodes.head.health.dump(
File.newTemporaryFile(s"local-"),
timeout,
chunkSize,
)
}.recoverWith { case NonFatal(e) =>
NonEmptyList.fromList(nodes.tail) match {
case Some(tail) =>
logger.info(
s"Could not get health dump from ${nodes.head.name}, trying the next local node",
e,
)
getLocalDump(tail)
case None => Future.failed(e)
}
}
}
val localDump = NonEmptyList
// The sorting is not necessary but makes testing easier
.fromList(consoleEnv.nodes.local.toList.sortBy(_.name))
.traverse(getLocalDump)
.map(_.toList)
consoleEnv.run {
val zippedHealthDump = List(remoteDumps, localDump).flatSequence.map { allDumps =>
outputFile.zipIn(allDumps.map(File(_)).iterator).pathAsString
}
Try(Await.result(zippedHealthDump, timeout.duration)) match {
case Success(result) => CommandSuccessful(result)
case Failure(e: TimeoutException) =>
CommandErrors.ConsoleTimeout.Error(timeout.asJavaApproximation)
case Failure(exception) => CommandErrors.CommandInternalError.ErrorWithException(exception)
}
}
}
}
class CommunityCantonHealthAdministration(override val consoleEnv: ConsoleEnvironment)
extends CantonHealthAdministration[CommunityCantonStatus] {
@Help.Summary("Aggregate status info of all participants and domains")
def status(): CommunityCantonStatus = {
CommunityCantonStatus.getStatus(
statusMap[DomainReference](consoleEnv.domains),
statusMap[ParticipantReference](consoleEnv.participants),
)
}
}

View File

@ -0,0 +1,31 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import com.digitalasset.canton.admin.api.client.data.CommunityCantonStatus
import com.digitalasset.canton.environment.CommunityEnvironment
import com.digitalasset.canton.health.admin.data.{DomainStatus, ParticipantStatus}
import io.circe.Encoder
import io.circe.generic.semiauto.deriveEncoder
import scala.annotation.nowarn
@nowarn("cat=lint-byname-implicit") // https://github.com/scala/bug/issues/12072
class CommunityHealthDumpGenerator(
override val environment: CommunityEnvironment,
override val grpcAdminCommandRunner: GrpcAdminCommandRunner,
) extends HealthDumpGenerator[CommunityCantonStatus] {
override protected implicit val statusEncoder: Encoder[CommunityCantonStatus] = {
import io.circe.generic.auto.*
import CantonHealthAdministrationEncoders.*
deriveEncoder[CommunityCantonStatus]
}
override def status(): CommunityCantonStatus = {
CommunityCantonStatus.getStatus(
statusMap(environment.config.domainsByString, DomainStatus.fromProtoV0),
statusMap(environment.config.participantsByString, ParticipantStatus.fromProtoV0),
)
}
}

View File

@ -0,0 +1,178 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import cats.Monad
import cats.syntax.alternative.*
import com.daml.error.{ErrorCategory, ErrorCode}
import com.digitalasset.canton.console.CommandErrors.{CommandError, GenericCommandError}
import com.digitalasset.canton.error.CantonErrorGroups.CommandErrorGroup
import com.digitalasset.canton.error.*
import com.digitalasset.canton.util.ErrorUtil
import org.slf4j.event.Level
import java.time.Duration
import scala.util.{Failure, Success, Try}
/** Response from a console command.
*/
sealed trait ConsoleCommandResult[+A] {
def toEither: Either[String, A]
def flatMap[B](f: A => ConsoleCommandResult[B]): ConsoleCommandResult[B] = this match {
case CommandSuccessful(a) => f(a)
case err: CommandError => err
}
def map[B](f: A => B): ConsoleCommandResult[B] = this match {
case CommandSuccessful(a) => CommandSuccessful(f(a))
case err: CommandError => err
}
}
object ConsoleCommandResult {
implicit val consoleCommandResultMonad: Monad[ConsoleCommandResult] =
new Monad[ConsoleCommandResult] {
override def flatMap[A, B](fa: ConsoleCommandResult[A])(
f: A => ConsoleCommandResult[B]
): ConsoleCommandResult[B] = fa.flatMap(f)
override def tailRecM[A, B](
a: A
)(f: A => ConsoleCommandResult[Either[A, B]]): ConsoleCommandResult[B] = {
def go(ccr: ConsoleCommandResult[Either[A, B]]): ConsoleCommandResult[B] = ccr match {
case CommandSuccessful(Left(a)) => go(f(a))
case CommandSuccessful(Right(b)) => CommandSuccessful(b)
case err: CommandError => err
}
go(CommandSuccessful(Left(a)))
}
override def pure[A](x: A): ConsoleCommandResult[A] = CommandSuccessful(x)
}
def fromEither[A](either: Either[String, A]): ConsoleCommandResult[A] =
either match {
case Left(err) => GenericCommandError(err)
case Right(value) => CommandSuccessful(value)
}
private[console] def runAll[Instance <: InstanceReferenceCommon, Result](
instances: Seq[Instance]
)(
action: Instance => ConsoleCommandResult[Result]
)(implicit consoleEnvironment: ConsoleEnvironment): Map[Instance, Result] =
consoleEnvironment.run {
forAll(instances)(action)
}
/** Call a console command on all instances.
* Will run all in sequence and will merge all failures.
* If nothing fails, the final CommandSuccessful result will be returned.
* @param action Action to perform on instances
* @return Successful if the action was successful for all instances, otherwise all the errors encountered merged into one.
*/
private[console] def forAll[Instance <: InstanceReferenceCommon, Result](
instances: Seq[Instance]
)(
action: Instance => ConsoleCommandResult[Result]
): ConsoleCommandResult[Map[Instance, Result]] = {
val (errors, results) = instances
.map(instance => instance -> Try(action(instance)))
.map {
case (instance, Success(CommandSuccessful(value))) => Right(instance -> value)
case (instance, Success(err: CommandError)) =>
Left(
s"(failure on ${instance.name}): ${err.cause}"
)
case (instance, Failure(t)) =>
Left(s"(exception on ${instance.name}: ${ErrorUtil.messageWithStacktrace(t)}")
}
.toList
.separate
if (errors.isEmpty) {
CommandSuccessful(results.toMap)
} else {
GenericCommandError(
s"Command failed on ${errors.length} out of ${instances.length} instances: ${errors.mkString(", ")}"
)
}
}
}
/** Successful command result
* @param value The value returned from the command
*/
final case class CommandSuccessful[+A](value: A) extends ConsoleCommandResult[A] {
override lazy val toEither: Either[String, A] = Right(value)
}
object CommandSuccessful {
def apply(): CommandSuccessful[Unit] = CommandSuccessful(())
}
// Each each in object CommandErrors, will have an error code that begins with `CA12` ('CA1' due to inheritance from CommunityAppError, '2' due to the argument)
object CommandErrors extends CommandErrorGroup {
sealed trait CommandError extends ConsoleCommandResult[Nothing] {
override lazy val toEither: Either[String, Nothing] = Left(cause)
def cause: String
}
sealed abstract class CantonCommandError(
override val cause: String,
override val throwableO: Option[Throwable] = None,
)(implicit override val code: ErrorCode)
extends BaseCantonError
with CommandError
sealed abstract class CommandErrorCode(id: String, category: ErrorCategory)
extends ErrorCode(id, category) {
override def errorConveyanceDocString: Option[String] = Some(
"These errors are shown as errors on the console."
)
}
object CommandInternalError
extends CommandErrorCode(
"CONSOLE_COMMAND_INTERNAL_ERROR",
ErrorCategory.SystemInternalAssumptionViolated,
) {
final case class ErrorWithException(throwable: Throwable)
extends CantonCommandError(
"An internal error has occurred while running a console command.",
Some(throwable),
)
final case class NullError()
extends CantonCommandError("Console command has returned 'null' as result.")
}
// The majority of the use cases of this error are for generic Either[..., ...] => ConsoleCommandResult[...] conversions
// Thus, it doesn't have an error code because the underlying error that is wrapped should provide the error code
// TODO(i6183) - replace uses of this wrapper with a CantonParentError wrapper except when parsing gRPC errors
final case class GenericCommandError(cause: String)
extends ConsoleCommandResult[Nothing]
with CommandError
object ConsoleTimeout
extends CommandErrorCode(
"CONSOLE_COMMAND_TIMED_OUT",
ErrorCategory.SystemInternalAssumptionViolated,
) {
final case class Error(timeout: Duration)
extends CantonCommandError(s"Condition never became true after ${timeout}")
}
object NodeNotStarted
extends CommandErrorCode(
"NODE_NOT_STARTED",
ErrorCategory.InvalidGivenCurrentSystemStateOther,
) {
override def logLevel: Level = Level.ERROR
final case class ErrorCanton(instance: LocalInstanceReferenceCommon)
extends CantonCommandError(s"Instance $instance has not been started. ")
}
}

View File

@ -0,0 +1,646 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import ammonite.util.Bind
import cats.syntax.either.*
import com.digitalasset.canton.admin.api.client.data.CantonStatus
import com.digitalasset.canton.config.CantonRequireTypes.InstanceName
import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveDouble, PositiveInt}
import com.digitalasset.canton.config.{
ConsoleCommandTimeout,
NonNegativeDuration,
NonNegativeFiniteDuration,
PositiveDurationSeconds,
ProcessingTimeout,
}
import com.digitalasset.canton.console.CommandErrors.{
CantonCommandError,
CommandInternalError,
GenericCommandError,
}
import com.digitalasset.canton.console.Help.{Description, Summary, Topic}
import com.digitalasset.canton.crypto.Fingerprint
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.environment.Environment
import com.digitalasset.canton.lifecycle.{FlagCloseable, Lifecycle}
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.protocol.SerializableContract
import com.digitalasset.canton.sequencing.{
GrpcSequencerConnection,
SequencerConnection,
SequencerConnections,
}
import com.digitalasset.canton.time.SimClock
import com.digitalasset.canton.topology.{Identifier, ParticipantId, PartyId}
import com.digitalasset.canton.tracing.{NoTracing, TraceContext, TracerProvider}
import com.digitalasset.canton.util.EitherUtil
import com.digitalasset.canton.{DomainAlias, LfPartyId}
import com.typesafe.scalalogging.Logger
import io.opentelemetry.api.trace.Tracer
import java.time.{Duration as JDuration, Instant}
import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.duration.Duration as SDuration
import scala.reflect.runtime.universe as ru
import scala.util.control.NonFatal
final case class NodeReferences[A, R <: A, L <: A](local: Seq[L], remote: Seq[R]) {
val all: Seq[A] = local ++ remote
}
/** The environment in which console commands are evaluated.
*/
@SuppressWarnings(Array("org.wartremover.warts.Any")) // required for `Binding[_]` usage
trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing {
type Env <: Environment
type DomainLocalRef <: LocalDomainReference
type DomainRemoteRef <: RemoteDomainReference
type Status <: CantonStatus
def consoleLogger: Logger = super.noTracingLogger
def health: CantonHealthAdministration[Status]
/** the underlying Canton runtime environment */
val environment: Env
/** determines the control exception thrown on errors */
val errorHandler: ConsoleErrorHandler = ThrowErrorHandler
/** the console for user facing output */
val consoleOutput: ConsoleOutput
/** The predef code itself which is executed before any script or repl command */
private[console] def predefCode(interactive: Boolean, noTty: Boolean = false): String =
consoleEnvironmentBindings.predefCode(interactive, noTty)
protected def consoleEnvironmentBindings: ConsoleEnvironmentBinding
private val tracerProvider =
TracerProvider.Factory(environment.configuredOpenTelemetry, "console")
private[console] val tracer: Tracer = tracerProvider.tracer
/** Definition of the startup order of local instances.
* Nodes support starting up in any order however to avoid delays/warnings we opt to start in the most desirable order
* for simple execution. (e.g. domains started before participants).
* Implementations should just return a int for the instance (typically just a static value based on type),
* and then the console will start these instances for lower to higher values.
*/
protected def startupOrderPrecedence(instance: LocalInstanceReferenceCommon): Int
/** The order that local nodes would ideally be started in. */
final val startupOrdering: Ordering[LocalInstanceReferenceCommon] =
(x: LocalInstanceReferenceCommon, y: LocalInstanceReferenceCommon) =>
startupOrderPrecedence(x) compare startupOrderPrecedence(y)
/** allows for injecting a custom admin command runner during tests */
protected def createAdminCommandRunner: ConsoleEnvironment => ConsoleGrpcAdminCommandRunner
protected override val loggerFactory: NamedLoggerFactory = environment.loggerFactory
private val commandTimeoutReference: AtomicReference[ConsoleCommandTimeout] =
new AtomicReference[ConsoleCommandTimeout](environment.config.parameters.timeouts.console)
private val featureSetReference: AtomicReference[HelperItems] =
new AtomicReference[HelperItems](HelperItems(environment.config.features.featureFlags))
/** Generate implementation specific help items for local domains */
protected def localDomainHelpItems(
scope: Set[FeatureFlag],
localDomain: DomainLocalRef,
): Seq[Help.Item]
/** Generate implementation specific help items for remote domains */
protected def remoteDomainHelpItems(
scope: Set[FeatureFlag],
remoteDomain: DomainRemoteRef,
): Seq[Help.Item]
private case class HelperItems(scope: Set[FeatureFlag]) {
lazy val participantHelperItems = {
// due to the use of reflection to grab the help-items, i need to write the following, repetitive stuff explicitly
val subItems =
if (participants.local.nonEmpty)
participants.local.headOption.toList.flatMap(p =>
Help.getItems(p, baseTopic = Seq("$participant"), scope = scope)
)
else if (participants.remote.nonEmpty)
participants.remote.headOption.toList.flatMap(p =>
Help.getItems(p, baseTopic = Seq("$participant"), scope = scope)
)
else Seq()
Help.Item("$participant", None, Summary(""), Description(""), Topic(Seq()), subItems)
}
lazy val domainHelperItems = {
val subItems =
if (domains.local.nonEmpty)
domains.local.headOption.toList.flatMap(localDomainHelpItems(scope, _))
else if (domains.remote.nonEmpty)
domains.remote.headOption.toList.flatMap(remoteDomainHelpItems(scope, _))
else Seq()
Help.Item("$domain", None, Summary(""), Description(""), Topic(Seq()), subItems)
}
lazy val filteredHelpItems = {
helpItems.filter(x => scope.contains(x.summary.flag))
}
lazy val all = filteredHelpItems :+ participantHelperItems :+ domainHelperItems
}
protected def timeouts: ProcessingTimeout = environment.config.parameters.timeouts.processing
/** @return maximum runtime of a console command
*/
def commandTimeouts: ConsoleCommandTimeout = commandTimeoutReference.get()
def setCommandTimeout(newTimeout: NonNegativeDuration): Unit = {
require(newTimeout.duration > SDuration.Zero, "The command timeout must be positive!")
commandTimeoutReference.updateAndGet(cur => cur.copy(bounded = newTimeout)).discard
}
def setLedgerCommandTimeout(newTimeout: NonNegativeDuration): Unit = {
require(newTimeout.duration > SDuration.Zero, "The ledger command timeout must be positive!")
commandTimeoutReference.updateAndGet(cur => cur.copy(ledgerCommand = newTimeout)).discard
}
/** returns the currently enabled feature sets */
def featureSet: Set[FeatureFlag] = featureSetReference.get().scope
def updateFeatureSet(flag: FeatureFlag, include: Boolean): Unit = {
val _ = featureSetReference.updateAndGet { x =>
val scope = if (include) x.scope + flag else x.scope - flag
HelperItems(scope)
}
}
/** Holder for top level values including their name, their value, and a description to display when `help` is printed.
*/
protected case class TopLevelValue[T](
nameUnsafe: String,
summary: String,
value: T,
topic: Seq[String] = Seq(),
)(implicit tag: ru.TypeTag[T]) {
// Surround with back-ticks to handle the case that name is a reserved keyword in scala.
lazy val asBind: Either[InstanceName.InvalidInstanceName, Bind[T]] =
InstanceName.create(nameUnsafe).map(name => Bind(s"`${name.unwrap}`", value))
lazy val asHelpItem: Help.Item =
Help.Item(nameUnsafe, None, Help.Summary(summary), Help.Description(""), Help.Topic(topic))
}
object TopLevelValue {
/** Provide all details but the value itself. A subsequent call can then specify the value from another location.
* This oddness is to allow the ConsoleEnvironment implementations to specify the values of node instances they
* use as scala's runtime reflection can't easily take advantage of the type members we have available here.
*/
case class Partial(name: String, summary: String, topics: Seq[String] = Seq.empty) {
def apply[T](value: T)(implicit t: ru.TypeTag[T]): TopLevelValue[T] =
TopLevelValue(name, summary, value, topics)
}
}
// lazy to prevent publication of this before this has been fully initialized
lazy val grpcAdminCommandRunner: ConsoleGrpcAdminCommandRunner = createAdminCommandRunner(this)
def runE[E, A](result: => Either[E, A]): A = {
run(ConsoleCommandResult.fromEither(result.leftMap(_.toString)))
}
/** Run a console command.
*/
@SuppressWarnings(Array("org.wartremover.warts.Null"))
def run[A](result: => ConsoleCommandResult[A]): A = {
val resultValue: ConsoleCommandResult[A] =
try {
result
} catch {
case err: Throwable =>
CommandInternalError.ErrorWithException(err).logWithContext()
err match {
case NonFatal(_) =>
// No need to rethrow err, as it has been logged and output
errorHandler.handleInternalError()
case _ =>
// Rethrow err, as it is a bad practice to discard fatal errors.
// As a result, the error may be printed several times,
// but there is no guarantee that the log is still working.
// So it is better to err on the safe side.
throw err
}
}
def invocationContext(): Map[String, String] =
findInvocationSite() match {
case Some((funcName, callSite)) => Map("function" -> funcName, "callsite" -> callSite)
case None => Map()
}
resultValue match {
case null =>
CommandInternalError.NullError().logWithContext(invocationContext())
errorHandler.handleInternalError()
case CommandSuccessful(value) =>
value
case err: CantonCommandError =>
err.logWithContext(invocationContext())
errorHandler.handleCommandFailure()
case err: GenericCommandError =>
val errMsg = findInvocationSite() match {
case Some((funcName, site)) =>
err.cause + s"\n Command ${funcName} invoked from ${site}"
case None => err.cause
}
logger.error(errMsg)
errorHandler.handleCommandFailure()
}
}
private def findInvocationSite(): Option[(String, String)] = {
val stack = Thread.currentThread().getStackTrace
// assumption: first few stack elements are all in our set of known packages. our call-site is
// the first entry outside of our package
// also skip all scala packages because a collection's map operation is not an informative call site
val myPackages =
Seq("com.digitalasset.canton.console", "com.digitalasset.canton.environment", "scala.")
def isKnown(element: StackTraceElement): Boolean =
myPackages.exists(element.getClassName.startsWith)
stack.sliding(2).collectFirst {
case Array(callee, caller) if isKnown(callee) && !isKnown(caller) =>
val drop = callee.getClassName.lastIndexOf(".") + 1
val funcName = callee.getClassName.drop(drop) + "." + callee.getMethodName
(funcName, s"${caller.getFileName}:${caller.getLineNumber}")
}
}
/** Print help for items in the top level scope.
*/
def help(): Unit = {
consoleOutput.info(Help.format(featureSetReference.get().filteredHelpItems: _*))
}
/** Print detailed help for a top-level item in the top level scope.
*/
def help(cmd: String): Unit =
consoleOutput.info(Help.forMethod(featureSetReference.get().all, cmd))
def helpItems: Seq[Help.Item] =
topLevelValues.map(_.asHelpItem) ++
Help.fromObject(ConsoleMacros) ++
Help.fromObject(this) :+
(
Help.Item(
"help",
None,
Help.Summary(
"Help with console commands; type help(\"<command>\") for detailed help for <command>"
),
Help.Description(""),
Help.Topic(Help.defaultTopLevelTopic),
),
) :+
(Help.Item(
"exit",
None,
Help.Summary("Leave the console"),
Help.Description(""),
Help.Topic(Help.defaultTopLevelTopic),
))
lazy val participants: NodeReferences[
ParticipantReference,
RemoteParticipantReference,
LocalParticipantReference,
] =
NodeReferences(
environment.config.participantsByString.keys.map(createParticipantReference).toSeq,
environment.config.remoteParticipantsByString.keys
.map(createRemoteParticipantReference)
.toSeq,
)
lazy val participantsX: NodeReferences[
ParticipantReferenceX,
RemoteParticipantReferenceX,
LocalParticipantReferenceX,
] =
NodeReferences(
environment.config.participantsByStringX.keys.map(createParticipantReferenceX).toSeq,
environment.config.remoteParticipantsByStringX.keys
.map(createRemoteParticipantReferenceX)
.toSeq,
)
lazy val domains: NodeReferences[DomainReference, DomainRemoteRef, DomainLocalRef] =
NodeReferences(
environment.config.domainsByString.keys.map(createDomainReference).toSeq,
environment.config.remoteDomainsByString.keys.map(createRemoteDomainReference).toSeq,
)
// the scala compiler / wartremover gets confused here if I use ++ directly
def mergeLocalInstances(
locals: Seq[LocalInstanceReferenceCommon]*
): Seq[LocalInstanceReferenceCommon] =
locals.flatten
def mergeRemoteInstances(remotes: Seq[InstanceReferenceCommon]*): Seq[InstanceReferenceCommon] =
remotes.flatten
lazy val nodes: NodeReferences[
InstanceReferenceCommon,
InstanceReferenceCommon,
LocalInstanceReferenceCommon,
] = {
NodeReferences(
mergeLocalInstances(participants.local, participantsX.local, domains.local),
mergeRemoteInstances(participants.remote, participantsX.remote, domains.remote),
)
}
protected def helpText(typeName: String, name: String) =
s"Manage $typeName '${name}'; type '${name} help' or '${name} help" + "(\"<methodName>\")' for more help"
protected val topicNodeReferences = "Node References"
protected val topicGenericNodeReferences = "Generic Node References"
protected val genericNodeReferencesDoc = " (.all, .local, .remote)"
protected def domainsTopLevelValue(
h: TopLevelValue.Partial,
domains: NodeReferences[DomainReference, DomainRemoteRef, DomainLocalRef],
): TopLevelValue[NodeReferences[DomainReference, DomainRemoteRef, DomainLocalRef]]
/** Supply the local domain value used by the implementation */
protected def localDomainTopLevelValue(
h: TopLevelValue.Partial,
d: DomainLocalRef,
): TopLevelValue[DomainLocalRef]
/** Supply the remote domain value used by the implementation */
protected def remoteDomainTopLevelValue(
h: TopLevelValue.Partial,
d: DomainRemoteRef,
): TopLevelValue[DomainRemoteRef]
/** Assemble top level values with their identifier name, value binding, and help description.
*/
protected def topLevelValues: Seq[TopLevelValue[_]] = {
val nodeTopic = Seq(topicNodeReferences)
val localParticipantBinds: Seq[TopLevelValue[_]] =
participants.local.map(p =>
TopLevelValue(p.name, helpText("participant", p.name), p, nodeTopic)
)
val remoteParticipantBinds: Seq[TopLevelValue[_]] =
participants.remote.map(p =>
TopLevelValue(p.name, helpText("remote participant", p.name), p, nodeTopic)
)
val localParticipantXBinds: Seq[TopLevelValue[_]] =
participantsX.local.map(p =>
TopLevelValue(p.name, helpText("participant x", p.name), p, nodeTopic)
)
val remoteParticipantXBinds: Seq[TopLevelValue[_]] =
participantsX.remote.map(p =>
TopLevelValue(p.name, helpText("remote participant x", p.name), p, nodeTopic)
)
val localDomainBinds: Seq[TopLevelValue[_]] =
domains.local.map(d =>
localDomainTopLevelValue(
TopLevelValue.Partial(d.name, helpText("local domain", d.name), nodeTopic),
d,
)
)
val remoteDomainBinds: Seq[TopLevelValue[_]] =
domains.remote.map(d =>
remoteDomainTopLevelValue(
TopLevelValue.Partial(d.name, helpText("remote domain", d.name), nodeTopic),
d,
)
)
val clockBinds: Option[TopLevelValue[_]] =
environment.simClock.map(cl =>
TopLevelValue("clock", "Simulated time", new SimClockCommand(cl))
)
val referencesTopic = Seq(topicGenericNodeReferences)
localParticipantBinds ++ remoteParticipantBinds ++
localParticipantXBinds ++ remoteParticipantXBinds ++
localDomainBinds ++ remoteDomainBinds ++ clockBinds.toList :+
TopLevelValue(
"participants",
"All participant nodes" + genericNodeReferencesDoc,
participants,
referencesTopic,
) :+
TopLevelValue(
"participantsX",
"All participant x nodes" + genericNodeReferencesDoc,
participantsX,
referencesTopic,
) :+
domainsTopLevelValue(
TopLevelValue
.Partial("domains", "All domain nodes" + genericNodeReferencesDoc, referencesTopic),
domains,
) :+
TopLevelValue("nodes", "All nodes" + genericNodeReferencesDoc, nodes, referencesTopic)
}
/** Bindings for ammonite
* Add a reference to this instance to resolve implicit references within the console
*/
lazy val bindings: Either[RuntimeException, IndexedSeq[Bind[_]]] = {
import cats.syntax.traverse.*
for {
bindsWithoutSelfAlias <- topLevelValues.traverse(_.asBind)
binds = bindsWithoutSelfAlias :+ selfAlias()
_ <- validateNameUniqueness(binds)
} yield binds.toIndexedSeq
}
private def validateNameUniqueness(binds: Seq[Bind[_]]) = {
val nonUniqueNames =
binds.map(_.name).groupBy(identity).collect {
case (name, occurrences) if occurrences.sizeIs > 1 =>
s"$name (${occurrences.size} occurrences)"
}
EitherUtil.condUnitE(
nonUniqueNames.isEmpty,
new IllegalStateException(
s"""Node names must be unique and must differ from reserved keywords. Please revisit node names in your config file.
|Offending names: ${nonUniqueNames.mkString("(", ", ", ")")}""".stripMargin
),
)
}
private def createParticipantReference(name: String): LocalParticipantReference =
new LocalParticipantReference(this, name)
private def createRemoteParticipantReference(name: String): RemoteParticipantReference =
new RemoteParticipantReference(this, name)
private def createParticipantReferenceX(name: String): LocalParticipantReferenceX =
new LocalParticipantReferenceX(this, name)
private def createRemoteParticipantReferenceX(name: String): RemoteParticipantReferenceX =
new RemoteParticipantReferenceX(this, name)
protected def createDomainReference(name: String): DomainLocalRef
protected def createRemoteDomainReference(name: String): DomainRemoteRef
/** So we can we make this available
*/
protected def selfAlias(): Bind[_] = Bind(ConsoleEnvironmentBinding.BindingName, this)
override def onClosed(): Unit = {
Lifecycle.close(grpcAdminCommandRunner, environment)(logger)
}
def closeChannels(): Unit = {
grpcAdminCommandRunner.closeChannels()
}
def startAll(): Unit = runE(environment.startAll())
def stopAll(): Unit = runE(environment.stopAll())
}
/** Expose a Canton [[environment.Environment]] in a way that's easy to deal with from a REPL.
*/
object ConsoleEnvironment {
trait Implicits {
import scala.language.implicitConversions
implicit def toInstanceReferenceExtensions(
instances: Seq[LocalInstanceReferenceCommon]
): LocalInstancesExtensions =
new LocalInstancesExtensions.Impl(instances)
/** Implicit maps an LfPartyId to a PartyId */
implicit def toPartId(lfPartyId: LfPartyId): PartyId = PartyId.tryFromLfParty(lfPartyId)
/** Extensions for many instance references
*/
implicit def toLocalDomainExtensions(
instances: Seq[LocalDomainReference]
): LocalInstancesExtensions =
new LocalDomainReferencesExtensions(instances)
/** Extensions for many participant references
*/
implicit def toParticipantReferencesExtensions(participants: Seq[ParticipantReferenceCommon])(
implicit consoleEnvironment: ConsoleEnvironment
): ParticipantReferencesExtensions =
new ParticipantReferencesExtensions(participants)
implicit def toLocalParticipantReferencesExtensions(
participants: Seq[LocalParticipantReference]
)(implicit consoleEnvironment: ConsoleEnvironment): LocalParticipantReferencesExtensions =
new LocalParticipantReferencesExtensions(participants)
/** Implicitly map strings to DomainAlias, Fingerprint and Identifier
*/
implicit def toDomainAlias(alias: String): DomainAlias = DomainAlias.tryCreate(alias)
implicit def toDomainAliases(aliases: Seq[String]): Seq[DomainAlias] =
aliases.map(DomainAlias.tryCreate)
implicit def toInstanceName(name: String): InstanceName = InstanceName.tryCreate(name)
implicit def toGrpcSequencerConnection(connection: String): SequencerConnection =
GrpcSequencerConnection.tryCreate(connection)
implicit def toSequencerConnections(connection: String): SequencerConnections =
SequencerConnections.single(GrpcSequencerConnection.tryCreate(connection))
implicit def toGSequencerConnection(
ref: InstanceReferenceWithSequencerConnection
): SequencerConnection =
ref.sequencerConnection
implicit def toGSequencerConnections(
ref: InstanceReferenceWithSequencerConnection
): SequencerConnections =
SequencerConnections.single(ref.sequencerConnection)
implicit def toIdentifier(id: String): Identifier = Identifier.tryCreate(id)
implicit def toFingerprint(fp: String): Fingerprint = Fingerprint.tryCreate(fp)
/** Implicitly map ParticipantReferences to the ParticipantId
*/
implicit def toParticipantId(reference: ParticipantReference): ParticipantId = reference.id
implicit def toParticipantIdX(reference: ParticipantReferenceX): ParticipantId = reference.id
/** Implicitly map an `Int` to a `NonNegativeInt`.
* @throws java.lang.IllegalArgumentException if `n` is negative
*/
implicit def toNonNegativeInt(n: Int): NonNegativeInt = NonNegativeInt.tryCreate(n)
/** Implicitly map an `Int` to a `PositiveInt`.
* @throws java.lang.IllegalArgumentException if `n` is not positive
*/
implicit def toPositiveInt(n: Int): PositiveInt = PositiveInt.tryCreate(n)
/** Implicitly map a Double to a `PositiveDouble`
* @throws java.lang.IllegalArgumentException if `n` is not positive
*/
implicit def toPositiveDouble(n: Double): PositiveDouble = PositiveDouble.tryCreate(n)
/** Implicitly map a `CantonTimestamp` to a `LedgerCreateTime`
*/
implicit def toLedgerCreateTime(ts: CantonTimestamp): SerializableContract.LedgerCreateTime =
SerializableContract.LedgerCreateTime(ts)
/** Implicitly convert a duration to a [[com.digitalasset.canton.config.NonNegativeDuration]]
* @throws java.lang.IllegalArgumentException if `duration` is negative
*/
implicit def durationToNonNegativeDuration(duration: SDuration): NonNegativeDuration =
NonNegativeDuration.tryFromDuration(duration)
/** Implicitly convert a duration to a [[com.digitalasset.canton.config.NonNegativeFiniteDuration]]
* @throws java.lang.IllegalArgumentException if `duration` is negative or infinite
*/
implicit def durationToNonNegativeFiniteDuration(
duration: SDuration
): NonNegativeFiniteDuration =
NonNegativeFiniteDuration.tryFromDuration(duration)
/** Implicitly convert a duration to a [[com.digitalasset.canton.config.PositiveDurationSeconds]]
*
* @throws java.lang.IllegalArgumentException if `duration` is not positive or not rounded to the second.
*/
implicit def durationToPositiveDurationRoundedSeconds(
duration: SDuration
): PositiveDurationSeconds =
PositiveDurationSeconds.tryFromDuration(duration)
}
object Implicits extends Implicits
}
class SimClockCommand(clock: SimClock) {
@Help.Description("Get current time")
def now: Instant = clock.now.toInstant
@Help.Description("Advance time to given time-point")
def advanceTo(timestamp: Instant): Unit = TraceContext.withNewTraceContext {
implicit traceContext =>
clock.advanceTo(CantonTimestamp.assertFromInstant(timestamp))
}
@Help.Description("Advance time by given time-period")
def advance(duration: JDuration): Unit = TraceContext.withNewTraceContext {
implicit traceContext =>
clock.advance(duration)
}
@Help.Summary("Reset simulation clock")
def reset(): Unit = clock.reset()
}

View File

@ -0,0 +1,74 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
/** To make the [[ConsoleEnvironment]] functionality conveniently available in ammonite we stash
* it in a implicit variable included as a predef before any script or REPL commands are run.
*/
class ConsoleEnvironmentBinding {
protected def consoleMacrosImport: String =
"import com.digitalasset.canton.console.ConsoleMacros._"
/** The predef code itself which is executed before any script or repl command */
def predefCode(interactive: Boolean, noTty: Boolean = false): String = {
val consoleEnvClassName = objectClassNameWithoutSuffix(ConsoleEnvironment.Implicits.getClass)
// this is the magic which allows us to use extensions such as `all start` on a sequence of instance references
// and those extensions to still obtain an implicit reference to the [[ConsoleEnvironment]] instance (where state like packages is kept)
val builder = new StringBuilder(s"""
|interp.configureCompiler(_.settings.processArgumentString("-Xsource:2.13"))
|import $consoleEnvClassName._
|import com.digitalasset.canton.topology.store.TimeQuery
|import com.digitalasset.canton.topology._
|import com.digitalasset.canton.topology.transaction._
|import com.digitalasset.canton.crypto._
|import com.digitalasset.canton.config._
|import com.digitalasset.canton.admin.api.client.data._
|import com.digitalasset.canton.participant.domain.DomainConnectionConfig
|import com.digitalasset.canton.SequencerAlias
|import com.digitalasset.canton.sequencing.SequencerConnection
|import com.digitalasset.canton.sequencing.SequencerConnections
|import com.digitalasset.canton.sequencing.GrpcSequencerConnection
|$consoleMacrosImport
|import com.digitalasset.canton.console.commands.DomainChoice
|import ${classOf[com.digitalasset.canton.console.BootstrapScriptException].getName}
|import com.digitalasset.canton.config.RequireTypes._
|import com.digitalasset.canton.participant.admin.ResourceLimits
|import java.time.Instant
|import scala.concurrent.ExecutionContextExecutor
|import scala.concurrent.duration._
|import scala.language.postfixOps
|implicit val consoleEnvironment = ${ConsoleEnvironmentBinding.BindingName}
|implicit val ec: ExecutionContextExecutor = consoleEnvironment.environment.executionContext
|implicit def fromSequencerConnection(connection: SequencerConnection): SequencerConnections =
| SequencerConnections.single(connection)
|def help = consoleEnvironment.help
|def help(s: String) = consoleEnvironment.help(s)
|def health = consoleEnvironment.health
|def logger = consoleEnvironment.consoleLogger
""".stripMargin)
// if we don't have a tty available switch the ammonite frontend to a dumb terminal
if (noTty) {
builder ++= System.lineSeparator()
builder ++= "repl.frontEnd() = new ammonite.repl.FrontEnds.JLineUnix(ammonite.compiler.Parsers)"
}
if (interactive) {
builder ++= System.lineSeparator()
builder ++= "repl.pprinter() = repl.pprinter().copy(additionalHandlers = { case p: com.digitalasset.canton.logging.pretty.PrettyPrinting => import com.digitalasset.canton.logging.pretty.Pretty._; p.toTree }, defaultHeight = 100)"
}
builder.result()
}
}
object ConsoleEnvironmentBinding {
/** where we hide the value of the active environment instance within the scope of our repl ******* */
private[console] val BindingName = "__replEnvironmentValue"
}

View File

@ -0,0 +1,32 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import scala.util.control.NoStackTrace
/** Handle an error from a console.
* We expect this implementation will either throw or exit, hence the [[scala.Nothing]] return type.
*/
trait ConsoleErrorHandler {
def handleCommandFailure(): Nothing
def handleInternalError(): Nothing
}
final class CommandFailure() extends Throwable("Command execution failed.") with NoStackTrace
final class CantonInternalError()
extends Throwable(
"Command execution failed due to an internal error. Please file a bug report."
)
with NoStackTrace
/** Throws a [[CommandFailure]] or [[CantonInternalError]] when a command fails.
* The throwables do not have a stacktraces, to avoid noise in the interactive console.
*/
object ThrowErrorHandler extends ConsoleErrorHandler {
override def handleCommandFailure(): Nothing = throw new CommandFailure()
override def handleInternalError(): Nothing = throw new CantonInternalError()
}

View File

@ -0,0 +1,127 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import com.digitalasset.canton.admin.api.client.GrpcCtlRunner
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{
CustomClientTimeout,
DefaultBoundedTimeout,
DefaultUnboundedTimeout,
ServerEnforcedTimeout,
}
import com.digitalasset.canton.config.RequireTypes.Port
import com.digitalasset.canton.config.{ClientConfig, ConsoleCommandTimeout, NonNegativeDuration}
import com.digitalasset.canton.environment.Environment
import com.digitalasset.canton.lifecycle.Lifecycle.CloseableChannel
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.networking.grpc.ClientChannelBuilder
import com.digitalasset.canton.tracing.{Spanning, TraceContext}
import io.opentelemetry.api.trace.Tracer
import java.util.concurrent.TimeUnit
import scala.collection.concurrent.TrieMap
import scala.concurrent.duration.{Duration, FiniteDuration}
import scala.concurrent.{ExecutionContextExecutor, blocking}
/** Attempt to run a grpc admin-api command against whatever is pointed at in the config
*/
class GrpcAdminCommandRunner(
environment: Environment,
val commandTimeouts: ConsoleCommandTimeout,
)(implicit tracer: Tracer)
extends NamedLogging
with AutoCloseable
with Spanning {
private implicit val executionContext: ExecutionContextExecutor =
environment.executionContext
override val loggerFactory: NamedLoggerFactory = environment.loggerFactory
private val grpcRunner = new GrpcCtlRunner(
environment.config.monitoring.logging.api.maxMessageLines,
environment.config.monitoring.logging.api.maxStringLength,
loggerFactory,
)
private val channels = TrieMap[(String, String, Port), CloseableChannel]()
def runCommandAsync[Result](
instanceName: String,
command: GrpcAdminCommand[_, _, Result],
clientConfig: ClientConfig,
token: Option[String],
)(implicit traceContext: TraceContext) = {
val awaitTimeout = command.timeoutType match {
case CustomClientTimeout(timeout) => timeout
// If a custom timeout for a console command is set, it involves some non-gRPC timeout mechanism
// -> we set the gRPC timeout to Inf, so gRPC never times out before the other timeout mechanism
case ServerEnforcedTimeout => NonNegativeDuration(Duration.Inf)
case DefaultBoundedTimeout => commandTimeouts.bounded
case DefaultUnboundedTimeout => commandTimeouts.unbounded
}
val callTimeout = awaitTimeout.duration match {
// Abort the command shortly before the console times out, to get a better error message
case x: FiniteDuration => Duration((x.toMillis * 9) / 10, TimeUnit.MILLISECONDS)
case x => x
}
val closeableChannel = getOrCreateChannel(instanceName, clientConfig)
logger.debug(s"Running on ${instanceName} command ${command} against ${clientConfig}")(
traceContext
)
(
awaitTimeout,
grpcRunner.run(instanceName, command, closeableChannel.channel, token, callTimeout),
)
}
def runCommand[Result](
instanceName: String,
command: GrpcAdminCommand[_, _, Result],
clientConfig: ClientConfig,
token: Option[String],
): ConsoleCommandResult[Result] =
withNewTrace[ConsoleCommandResult[Result]](command.fullName) { implicit traceContext => span =>
span.setAttribute("instance_name", instanceName)
val (awaitTimeout, commandET) = runCommandAsync(instanceName, command, clientConfig, token)
val apiResult =
awaitTimeout.await(
s"Running on ${instanceName} command ${command} against ${clientConfig}"
)(
commandET.value
)
// convert to a console command result
apiResult.toResult
}
private def getOrCreateChannel(
instanceName: String,
clientConfig: ClientConfig,
): CloseableChannel =
blocking(synchronized {
val addr = (instanceName, clientConfig.address, clientConfig.port)
channels.getOrElseUpdate(
addr,
new CloseableChannel(
ClientChannelBuilder.createChannelToTrustedServer(clientConfig),
logger,
s"ConsoleCommand",
),
)
})
override def close(): Unit = {
closeChannels()
}
def closeChannels(): Unit = {
channels.values.foreach(_.close())
channels.clear()
}
}
class ConsoleGrpcAdminCommandRunner(consoleEnvironment: ConsoleEnvironment)
extends GrpcAdminCommandRunner(
consoleEnvironment.environment,
consoleEnvironment.commandTimeouts,
)(consoleEnvironment.tracer)

View File

@ -0,0 +1,847 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import better.files.File
import cats.syntax.either.*
import cats.syntax.functor.*
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.classic.{Level, Logger}
import ch.qos.logback.core.spi.AppenderAttachable
import ch.qos.logback.core.{Appender, FileAppender}
import com.daml.ledger.api.v1.commands.{Command, CreateCommand, ExerciseCommand}
import com.daml.ledger.api.v1.event.CreatedEvent
import com.daml.ledger.api.v1.value.Value.Sum
import com.daml.ledger.api.v1.value.{
Identifier as IdentifierV1,
List as ListV1,
Optional,
Record,
RecordField,
Value,
}
import com.daml.lf.value.Value.ContractId
import com.digitalasset.canton.DomainAlias
import com.digitalasset.canton.admin.api.client.commands.LedgerApiTypeWrappers.ContractData
import com.digitalasset.canton.admin.api.client.data.{ListPartiesResult, TemplateId}
import com.digitalasset.canton.concurrent.Threading
import com.digitalasset.canton.config.NonNegativeDuration
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.ConsoleEnvironment.Implicits.*
import com.digitalasset.canton.crypto.{CryptoPureApi, Salt}
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.logging.{LastErrorsAppender, NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection
import com.digitalasset.canton.participant.admin.repair.RepairService
import com.digitalasset.canton.participant.config.{AuthServiceConfig, BaseParticipantConfig}
import com.digitalasset.canton.participant.ledger.api.JwtTokenUtilities
import com.digitalasset.canton.protocol.SerializableContract.LedgerCreateTime
import com.digitalasset.canton.protocol.*
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.tracing.{NoTracing, TraceContext}
import com.digitalasset.canton.util.BinaryFileUtil
import com.digitalasset.canton.version.ProtocolVersion
import com.google.protobuf.ByteString
import com.typesafe.scalalogging.LazyLogging
import io.circe.Encoder
import io.circe.generic.semiauto.deriveEncoder
import io.circe.syntax.*
import java.io.File as JFile
import java.time.Instant
import scala.annotation.nowarn
import scala.collection.mutable
import scala.concurrent.duration.*
import scala.jdk.CollectionConverters.*
trait ConsoleMacros extends NamedLogging with NoTracing {
import scala.reflect.runtime.universe.*
@Help.Summary("Console utilities")
@Help.Group("Utilities")
object utils extends Helpful {
@Help.Summary("Reflective inspection of object arguments, handy to inspect case class objects")
@Help.Description(
"Return the list field names of the given object. Helpful function when inspecting the return result."
)
def object_args[T: TypeTag](obj: T): List[String] = type_args[T]
@Help.Summary("Reflective inspection of type arguments, handy to inspect case class types")
@Help.Description(
"Return the list of field names of the given type. Helpful function when creating new objects for requests."
)
def type_args[T: TypeTag]: List[String] =
typeOf[T].members.collect {
case m: MethodSymbol if m.isCaseAccessor => s"${m.name}:${m.returnType}"
}.toList
@Help.Summary("Wait for a condition to become true, using default timeouts")
@Help.Description("""
|Wait until condition becomes true, with a timeout taken from the parameters.timeouts.console.bounded
|configuration parameter.""")
final def retry_until_true(
condition: => Boolean
)(implicit
env: ConsoleEnvironment
): Unit = retry_until_true(env.commandTimeouts.bounded)(
condition,
s"Condition never became true within ${env.commandTimeouts.bounded.unwrap}",
)
@Help.Summary("Wait for a condition to become true")
@Help.Description("""Wait `timeout` duration until `condition` becomes true.
| Retry evaluating `condition` with an exponentially increasing back-off up to `maxWaitPeriod` duration between retries.
|""")
@SuppressWarnings(Array("org.wartremover.warts.Var", "org.wartremover.warts.While"))
final def retry_until_true(
timeout: NonNegativeDuration,
maxWaitPeriod: NonNegativeDuration = 10.seconds,
)(
condition: => Boolean,
failure: => String = s"Condition never became true within $timeout",
): Unit = {
val deadline = timeout.asFiniteApproximation.fromNow
var isCompleted = condition
var waitMillis = 1L
while (!isCompleted) {
val timeLeft = deadline.timeLeft
if (timeLeft > Duration.Zero) {
val remaining = (timeLeft min (waitMillis.millis)) max 1.millis
Threading.sleep(remaining.toMillis)
// capped exponentially back off
waitMillis = (waitMillis * 2) min maxWaitPeriod.duration.toMillis
isCompleted = condition
} else {
throw new IllegalStateException(failure)
}
}
}
@Help.Summary("Wait until all topology changes have been effected on all accessible nodes")
def synchronize_topology(
timeoutO: Option[NonNegativeDuration] = None
)(implicit env: ConsoleEnvironment): Unit = {
ConsoleMacros.utils.retry_until_true(timeoutO.getOrElse(env.commandTimeouts.bounded)) {
env.nodes.all.forall(_.topology.synchronisation.is_idle())
}
}
@Help.Summary("Create a navigator ui-backend.conf for a participant")
def generate_navigator_conf(
participant: LocalParticipantReference,
file: Option[String] = None,
): JFile = {
val conf =
participant.parties
.hosted()
.map(x => x.party)
.map(party => {
s""" ${party.uid.id.unwrap} {
| party = "${party.uid.toProtoPrimitive}"
| password = password
| }
|""".stripMargin
})
.mkString("\n")
val port = participant.config.ledgerApi.port
val targetFile = file.map(File(_)).getOrElse(File(s"ui-backend-${participant.name}.conf"))
val instructions =
s"daml navigator server localhost ${port.unwrap} -t wallclock --port ${(port + 4).unwrap.toString} -c ${targetFile.name}"
targetFile.overwrite("// run with\n// ")
targetFile.appendLines(instructions, "users {")
targetFile.appendText(conf)
targetFile.appendLine("}")
targetFile.toJava
}
@nowarn("cat=lint-byname-implicit") // https://github.com/scala/bug/issues/12072
private object GenerateDamlScriptParticipantsConf {
import ConsoleEnvironment.Implicits.*
private val filename = "participant-config.json"
case class LedgerApi(host: String, port: Int)
// Keys in the exported JSON should have snake_case
case class Participants(
default_participant: Option[LedgerApi],
participants: Map[String, LedgerApi],
party_participants: Map[String, String],
)
implicit val ledgerApiEncoder: Encoder[LedgerApi] = deriveEncoder[LedgerApi]
implicit val participantsEncoder: Encoder[Participants] = deriveEncoder[Participants]
private def partyIdToParticipants(
useParticipantAlias: Boolean,
uidToAlias: Map[ParticipantId, String],
)(implicit env: ConsoleEnvironment): Map[String, String] = {
def participantReference(p: ParticipantId) = if (useParticipantAlias)
uidToAlias.getOrElse(p, p.uid.toProtoPrimitive)
else p.uid.toProtoPrimitive
def partyIdToParticipant(p: ListPartiesResult) = p.participants.headOption.map {
participantDomains =>
(p.party.filterString, participantReference(participantDomains.participant))
}
val partyAndParticipants =
env.participants.all.flatMap(_.parties.list().flatMap(partyIdToParticipant(_).toList))
val allPartiesSingleParticipant =
partyAndParticipants.groupBy { case (partyId, _) => partyId }.forall {
case (_, participants) => participants.sizeCompare(1) <= 0
}
if (!allPartiesSingleParticipant)
logger.info(
"Some parties are hosted on more than one participant. " +
"For such parties, only one participant will be exported to the generated config file."
)
partyAndParticipants.toMap
}
def apply(
file: Option[String] = None,
useParticipantAlias: Boolean = true,
defaultParticipant: Option[ParticipantReference] = None,
)(implicit env: ConsoleEnvironment): JFile = {
def toLedgerApi(participantConfig: BaseParticipantConfig) =
LedgerApi(
participantConfig.clientLedgerApi.address,
participantConfig.clientLedgerApi.port.unwrap,
)
def participantValue(p: ParticipantReference): String =
if (useParticipantAlias) p.name else p.uid.toProtoPrimitive
val allParticipants = env.participants.all
val participantsData =
allParticipants.map(p => (participantValue(p), toLedgerApi(p.config))).toMap
val uidToAlias = allParticipants.map(p => (p.id, p.name)).toMap
val default_participant =
defaultParticipant.map(participantReference => toLedgerApi(participantReference.config))
val participantJson = Participants(
default_participant,
participantsData,
partyIdToParticipants(useParticipantAlias, uidToAlias),
).asJson.spaces2
val targetFile = file.map(File(_)).getOrElse(File(filename))
targetFile.overwrite(participantJson).appendLine()
targetFile.toJava
}
}
@Help.Summary("Create a participants config for Daml script")
@Help.Description(
"""The generated config can be passed to `daml script` via the `participant-config` parameter.
|More information about the file format can be found in the `documentation <https://docs.daml.com/daml-script/index.html#using-daml-script-in-distributed-topologies>`_:
|It takes three arguments:
|- file (default to "participant-config.json")
|- useParticipantAlias (default to true): participant aliases are used instead of UIDs
|- defaultParticipant (default to None): adds a default participant if provided
|"""
)
def generate_daml_script_participants_conf(
file: Option[String] = None,
useParticipantAlias: Boolean = true,
defaultParticipant: Option[ParticipantReference] = None,
)(implicit env: ConsoleEnvironment): JFile =
GenerateDamlScriptParticipantsConf(
file,
useParticipantAlias,
defaultParticipant,
)
// TODO(i7387): add check that flag is set
@Help.Summary(
"Register `AutoCloseable` object to be shutdown if Canton is shut down",
FeatureFlag.Testing,
)
def auto_close(closeable: AutoCloseable)(implicit environment: ConsoleEnvironment): Unit = {
environment.environment.addUserCloseable(closeable)
}
@Help.Summary("Convert contract data to a contract instance.")
@Help.Description(
"""The `utils.contract_data_to_instance` bridges the gap between `participant.ledger_api.acs` commands that
|return various pieces of "contract data" and the `participant.repair.add` command used to add "contract instances"
|as part of repair workflows. Such workflows (for example migrating contracts from other Daml ledgers to Canton
|participants) typically consist of extracting contract data using `participant.ledger_api.acs` commands,
|modifying the contract data, and then converting the `contractData` using this function before finally
|adding the resulting contract instances to Canton participants via `participant.repair.add`.
|Obtain the `contractData` by invoking `.toContractData` on the `WrappedCreatedEvent` returned by the
|corresponding `participant.ledger_api.acs.of_party` or `of_all` call. The `ledgerTime` parameter should be
|chosen to be a time meaningful to the domain on which you plan to subsequently invoke `participant.repair.add`
|on and will be retained alongside the contract instance by the `participant.repair.add` invocation."""
)
def contract_data_to_instance(contractData: ContractData, ledgerTime: Instant)(implicit
env: ConsoleEnvironment
): SerializableContract =
TraceContext.withNewTraceContext { implicit traceContext =>
env.runE(
RepairService.ContractConverter.contractDataToInstance(
contractData.templateId.toIdentifier,
contractData.createArguments,
contractData.signatories,
contractData.observers,
contractData.inheritedContractId,
contractData.ledgerCreateTime.map(_.toInstant).getOrElse(ledgerTime),
contractData.contractSalt,
)
)
}
@Help.Summary("Convert a contract instance to contract data.")
@Help.Description(
"""The `utils.contract_instance_to_data` converts a Canton "contract instance" to "contract data", a format more
|amenable to inspection and modification as part of repair workflows. This function consumes the output of
|the `participant.testing` commands and can thus be employed in workflows geared at verifying the contents of
|contracts for diagnostic purposes and in environments in which the "features.enable-testing-commands"
|configuration can be (at least temporarily) enabled."""
)
def contract_instance_to_data(
contract: SerializableContract
)(implicit env: ConsoleEnvironment): ContractData =
env.runE(
RepairService.ContractConverter.contractInstanceToData(contract).map {
case (
templateId,
createArguments,
signatories,
observers,
contractId,
contractSaltO,
ledgerCreateTime,
) =>
ContractData(
TemplateId.fromIdentifier(templateId),
createArguments,
signatories,
observers,
contractId,
contractSaltO,
Some(ledgerCreateTime.ts.underlying),
)
}
)
@Help.Summary("Recompute authenticated contract ids.")
@Help.Description(
"""The `utils.recompute_contract_ids` regenerates "contract ids" of multiple contracts after their contents have
|changed. Starting from protocol version 4, Canton uses the so called authenticated contract ids which depend
|on the details of the associated contracts. When aspects of a contract such as the parties involved change as
|part of repair or export/import procedure, the corresponding contract id must be recomputed."""
)
def recompute_contract_ids(
participant: LocalParticipantReference,
acs: Seq[SerializableContract],
protocolVersion: ProtocolVersion,
): (Seq[SerializableContract], Map[LfContractId, LfContractId]) = {
val contractIdMappings = mutable.Map.empty[LfContractId, LfContractId]
// We assume ACS events are in order
val remappedCIds = acs.map { contract =>
// Update the referenced contract ids
val contractInstanceWithUpdatedContractIdReferences =
SerializableRawContractInstance
.create(
contract.rawContractInstance.contractInstance.map(_.mapCid(contractIdMappings)),
AgreementText.empty, // Empty is fine, because the agreement text is not used when generating the raw serializable contract hash
)
.valueOr(err =>
throw new RuntimeException(
s"Could not create serializable raw contract instance: $err"
)
)
val LfContractId.V1(discriminator, _) = contract.contractId
val contractSalt = contract.contractSalt.getOrElse(
throw new IllegalArgumentException("Missing contract salt")
)
val pureCrypto = participant.underlying
.map(_.cryptoPureApi)
.getOrElse(sys.error("where is my crypto?"))
// Compute the new contract id
val newContractId =
generate_contract_id(
cryptoPureApi = pureCrypto,
rawContract = contractInstanceWithUpdatedContractIdReferences,
createdAt = contract.ledgerCreateTime.ts,
discriminator = discriminator,
contractSalt = contractSalt,
metadata = contract.metadata,
)
// Update the contract id mappings with the current contract's id
contractIdMappings += contract.contractId -> newContractId
// Update the contract with the new contract id and recomputed instance
contract
.copy(
contractId = newContractId,
rawContractInstance = contractInstanceWithUpdatedContractIdReferences,
)
}
remappedCIds -> Map.from(contractIdMappings)
}
@Help.Summary("Generate authenticated contract id.")
@Help.Description(
"""The `utils.generate_contract_id` generates "contract id" of a contract. Starting from protocol version 4,
|Canton uses the so called authenticated contract ids which depend on the details of the associated contracts.
|When aspects of a contract such as the parties involved change as part of repair or export/import procedure,
|the corresponding contract id must be recomputed. This function can be used as a tool to generate an id for
|an arbitrary contract content"""
)
def generate_contract_id(
cryptoPureApi: CryptoPureApi,
rawContract: SerializableRawContractInstance,
createdAt: CantonTimestamp,
discriminator: LfHash,
contractSalt: Salt,
metadata: ContractMetadata,
): ContractId.V1 = {
val unicumGenerator = new UnicumGenerator(cryptoPureApi)
val cantonContractIdVersion = AuthenticatedContractIdVersionV2
val unicum = unicumGenerator
.recomputeUnicum(
contractSalt,
LedgerCreateTime(createdAt),
metadata,
rawContract,
cantonContractIdVersion,
)
.valueOr(err => throw new RuntimeException(err))
cantonContractIdVersion.fromDiscriminator(discriminator, unicum)
}
@Help.Summary("Writes several Protobuf messages to a file.")
def write_to_file(data: Seq[scalapb.GeneratedMessage], fileName: String): Unit =
File(fileName).outputStream.foreach { os =>
data.foreach(_.writeDelimitedTo(os))
}
@Help.Summary("Reads several Protobuf messages from a file.")
@Help.Description("Fails with an exception, if the file can't be read or parsed.")
def read_all_messages_from_file[A <: scalapb.GeneratedMessage](
fileName: String
)(implicit companion: scalapb.GeneratedMessageCompanion[A]): Seq[A] =
File(fileName).inputStream
.apply { is =>
Seq.unfold(()) { _ =>
companion.parseDelimitedFrom(is).map(_ -> ())
}
}
@Help.Summary("Writes a Protobuf message to a file.")
def write_to_file(data: scalapb.GeneratedMessage, fileName: String): Unit =
write_to_file(Seq(data), fileName)
@Help.Summary("Reads a single Protobuf message from a file.")
@Help.Description("Fails with an exception, if the file can't be read or parsed.")
def read_first_message_from_file[A <: scalapb.GeneratedMessage](
fileName: String
)(implicit companion: scalapb.GeneratedMessageCompanion[A]): A =
File(fileName).inputStream
.apply(companion.parseDelimitedFrom)
.getOrElse(
throw new IllegalArgumentException(
s"Unable to read ${companion.getClass.getSimpleName} from $fileName."
)
)
@Help.Summary("Writes a ByteString to a file.")
def write_to_file(data: ByteString, fileName: String): Unit =
BinaryFileUtil.writeByteStringToFile(fileName, data)
@Help.Summary("Reads a ByteString from a file.")
@Help.Description("Fails with an exception, if the file can't be read.")
def read_byte_string_from_file(fileName: String)(implicit env: ConsoleEnvironment): ByteString =
env.runE(BinaryFileUtil.readByteStringFromFile(fileName))
}
@Help.Summary("Canton development and testing utilities", FeatureFlag.Testing)
@Help.Group("Ledger Api Testing")
object ledger_api_utils extends Helpful {
private def buildIdentifier(packageId: String, module: String, template: String): IdentifierV1 =
IdentifierV1(
packageId = packageId,
moduleName = module,
entityName = template,
)
private def productToLedgerApiRecord(product: Product): Sum.Record = Value.Sum.Record(
Record(fields =
product.productIterator
.map(mapToLedgerApiValue)
.map(v => RecordField(value = Some(v)))
.toSeq
)
)
private def mapToLedgerApiValue(value: Any): Value = {
// assuming that String.toString = id, we'll just map any Map to a string map without casting
def safeMapCast(map: Map[_, _]): Map[String, Any] = map.map { case (key, value) =>
(key.toString, value)
}
val x: Value.Sum = value match {
case x: Int => Value.Sum.Int64(x.toLong)
case x: Long => Value.Sum.Int64(x)
case x: PartyId => Value.Sum.Party(x.toLf)
case x: Float => Value.Sum.Numeric(s"$x")
case x: Double => Value.Sum.Numeric(s"$x")
case x: String => Value.Sum.Text(x)
case x: Boolean => Value.Sum.Bool(x)
case x: Seq[Any] => Value.Sum.List(value = ListV1(x.map(mapToLedgerApiValue)))
case x: LfContractId => Value.Sum.ContractId(x.coid)
case x: Instant => Value.Sum.Timestamp(x.toEpochMilli * 1000L)
case x: Option[Any] => Value.Sum.Optional(Optional(value = x.map(mapToLedgerApiValue)))
case x: Value.Sum => x
case x: Map[_, _] => Value.Sum.Record(buildArguments(safeMapCast(x)))
case x: (Any, Any) => productToLedgerApiRecord(x)
case x: (Any, Any, Any) => productToLedgerApiRecord(x)
case _ =>
throw new UnsupportedOperationException(
s"value type not yet implemented: ${value.getClass}"
)
}
Value(x)
}
private def mapToRecordField(item: (String, Any)): RecordField =
RecordField(
label = item._1,
value = Some(mapToLedgerApiValue(item._2)),
)
private def buildArguments(map: Map[String, Any]): Record =
Record(
fields = map.map(mapToRecordField).toSeq
)
@Help.Summary("Build create command", FeatureFlag.Testing)
def create(
packageId: String,
module: String,
template: String,
arguments: Map[String, Any],
): Command =
Command().withCreate(
CreateCommand(
templateId = Some(buildIdentifier(packageId, module, template)),
createArguments = Some(buildArguments(arguments)),
)
)
@Help.Summary("Build exercise command", FeatureFlag.Testing)
def exercise(
packageId: String,
module: String,
template: String,
choice: String,
arguments: Map[String, Any],
contractId: String,
): Command =
Command().withExercise(
ExerciseCommand(
templateId = Some(buildIdentifier(packageId, module, template)),
choice = choice,
choiceArgument = Some(Value(Value.Sum.Record(buildArguments(arguments)))),
contractId = contractId,
)
)
@Help.Summary("Build exercise command from CreatedEvent", FeatureFlag.Testing)
def exercise(choice: String, arguments: Map[String, Any], event: CreatedEvent): Command = {
def getOrThrow(desc: String, opt: Option[String]): String =
opt.getOrElse(
throw new IllegalArgumentException(s"Corrupt created event ${event} without ${desc}")
)
exercise(
getOrThrow(
"packageId",
event.templateId
.map(_.packageId),
),
getOrThrow("moduleName", event.templateId.map(_.moduleName)),
getOrThrow("template", event.templateId.map(_.entityName)),
choice,
arguments,
event.contractId,
)
}
// intentionally not publicly documented
object jwt {
def generate_unsafe_token_for_participant(
participant: LocalParticipantReference,
admin: Boolean,
applicationId: String,
): Map[PartyId, String] = {
val secret = participant.config.ledgerApi.authServices
.collectFirst { case AuthServiceConfig.UnsafeJwtHmac256(secret, _, _) =>
secret.unwrap
}
.getOrElse("notasecret")
participant.parties
.hosted()
.map(_.party)
.map(x =>
(
x,
generate_unsafe_jwt256_token(
secret = secret,
admin = admin,
readAs = List(x.toLf),
actAs = List(x.toLf),
ledgerId = Some(participant.id.uid.id.unwrap),
applicationId = Some(applicationId),
),
)
)
.toMap
}
def generate_unsafe_jwt256_token(
secret: String,
admin: Boolean,
readAs: List[String],
actAs: List[String],
ledgerId: Option[String],
applicationId: Option[String],
): String = JwtTokenUtilities.buildUnsafeToken(
secret = secret,
admin = admin,
readAs = readAs,
actAs = actAs,
ledgerId = ledgerId,
applicationId = applicationId,
)
}
}
@Help.Summary("Logging related commands")
@Help.Group("Logging")
object logging extends Helpful {
@SuppressWarnings(Array("org.wartremover.warts.Null"))
@Help.Summary("Dynamically change log level (TRACE, DEBUG, INFO, WARN, ERROR, OFF, null)")
def set_level(loggerName: String = "com.digitalasset.canton", level: String): Unit = {
if (Seq("com.digitalasset.canton", "com.daml").exists(loggerName.startsWith))
System.setProperty("LOG_LEVEL_CANTON", level)
val logger = getLogger(loggerName)
if (level == "null")
logger.setLevel(null)
else
logger.setLevel(Level.valueOf(level))
}
@Help.Summary("Determine current logging level")
def get_level(loggerName: String = "com.digitalasset.canton"): Option[Level] =
Option(getLogger(loggerName).getLevel)
private def getLogger(loggerName: String): Logger = {
import org.slf4j.LoggerFactory
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
val logger: Logger = LoggerFactory.getLogger(loggerName).asInstanceOf[Logger]
logger
}
private def getAppenders(logger: Logger): List[Appender[ILoggingEvent]] = {
def go(currentAppender: Appender[ILoggingEvent]): List[Appender[ILoggingEvent]] = {
currentAppender match {
case attachable: AppenderAttachable[ILoggingEvent @unchecked] =>
attachable.iteratorForAppenders().asScala.toList.flatMap(go)
case appender: Appender[ILoggingEvent] => List(appender)
}
}
logger.iteratorForAppenders().asScala.toList.flatMap(go)
}
private lazy val rootLogger = getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)
private lazy val allAppenders = getAppenders(rootLogger)
private lazy val lastErrorsAppender: LastErrorsAppender = {
findAppender("LAST_ERRORS") match {
case Some(lastErrorsAppender: LastErrorsAppender) =>
lastErrorsAppender
case _ =>
logger.error(s"Log appender for last errors not found/configured")
throw new CommandFailure()
}
}
private def findAppender(appenderName: String): Option[Appender[ILoggingEvent]] =
Option(rootLogger.getAppender(appenderName))
.orElse(allAppenders.find(_.getName == appenderName))
private def renderError(errorEvent: ILoggingEvent): String = {
findAppender("FILE") match {
case Some(appender: FileAppender[ILoggingEvent]) =>
ByteString.copyFrom(appender.getEncoder.encode(errorEvent)).toStringUtf8
case _ => errorEvent.getFormattedMessage
}
}
@Help.Summary("Returns the last errors (trace-id -> error event) that have been logged locally")
def last_errors(): Map[String, String] =
lastErrorsAppender.lastErrors.fmap(renderError)
@Help.Summary("Returns log events for an error with the same trace-id")
def last_error_trace(traceId: String): Seq[String] = {
lastErrorsAppender.lastErrorTrace(traceId) match {
case Some(events) => events.map(renderError)
case None =>
logger.error(s"No events found for last error trace-id $traceId")
throw new CommandFailure()
}
}
}
@Help.Summary("Configure behaviour of console")
@Help.Group("Console")
object console extends Helpful {
@Help.Summary("Yields the timeout for running console commands")
@Help.Description(
"Yields the timeout for running console commands. " +
"When the timeout has elapsed, the console stops waiting for the command result. " +
"The command will continue running in the background."
)
def command_timeout(implicit env: ConsoleEnvironment): NonNegativeDuration =
env.commandTimeouts.bounded
@Help.Summary("Sets the timeout for running console commands.")
@Help.Description(
"Sets the timeout for running console commands. " +
"When the timeout has elapsed, the console stops waiting for the command result. " +
"The command will continue running in the background. " +
"The new timeout must be positive."
)
def set_command_timeout(newTimeout: NonNegativeDuration)(implicit
env: ConsoleEnvironment
): Unit =
env.setCommandTimeout(newTimeout)
// this command is intentionally not documented as part of the help system
def disable_features(flag: FeatureFlag)(implicit env: ConsoleEnvironment): Unit = {
env.updateFeatureSet(flag, include = false)
}
// this command is intentionally not documented as part of the help system
def enable_features(flag: FeatureFlag)(implicit env: ConsoleEnvironment): Unit = {
env.updateFeatureSet(flag, include = true)
}
}
}
object ConsoleMacros extends ConsoleMacros with NamedLogging {
val loggerFactory = NamedLoggerFactory.root
}
object DebuggingHelpers extends LazyLogging {
def get_active_contracts(
ref: LocalParticipantReference,
limit: PositiveInt = PositiveInt.tryCreate(1000000),
): (Map[String, String], Map[String, TemplateId]) =
get_active_contracts_helper(
ref,
alias => ref.testing.pcs_search(alias, activeSet = true, limit = limit),
)
def get_active_contracts_from_internal_db_state(
ref: ParticipantReference,
state: SyncStateInspection,
limit: PositiveInt = PositiveInt.tryCreate(1000000),
): (Map[String, String], Map[String, TemplateId]) =
get_active_contracts_helper(
ref,
alias =>
TraceContext.withNewTraceContext(implicit traceContext =>
state.findContracts(alias, None, None, None, limit.value)
),
)
private def get_active_contracts_helper(
ref: ParticipantReference,
lookup: DomainAlias => Seq[(Boolean, SerializableContract)],
): (Map[String, String], Map[String, TemplateId]) = {
val syncAcs = ref.domains
.list_connected()
.map(_.domainAlias)
.flatMap(lookup)
.collect {
case (active, sc) if active =>
(sc.contractId.coid, sc.contractInstance.unversioned.template.qualifiedName.toString())
}
.toMap
val lapiAcs = ref.ledger_api.acs.of_all().map(ev => (ev.event.contractId, ev.templateId)).toMap
(syncAcs, lapiAcs)
}
def diff_active_contracts(ref: LocalParticipantReference, limit: Int = 1000000): Unit = {
val (syncAcs, lapiAcs) = get_active_contracts(ref, limit)
if (syncAcs.sizeCompare(lapiAcs) != 0) {
logger.error(s"Sync ACS differs ${syncAcs.size} from Ledger API ACS ${lapiAcs.size} in size")
}
val lapiSet = lapiAcs.keySet
val syncSet = syncAcs.keySet
def compare[V](
explain: String,
lft: Set[String],
rght: Set[String],
payload: Map[String, V],
) = {
val delta = lft.diff(rght)
delta.foreach { key =>
logger.info(s"${explain} ${key} ${payload.getOrElse(key, sys.error("should be there"))}")
}
}
compare("Active in LAPI but not in SYNC", lapiSet, syncSet, lapiAcs)
compare("Active in SYNC but not in LAPI", syncSet, lapiSet, syncAcs)
}
def active_contracts_by_template(
ref: LocalParticipantReference,
limit: Int = 1000000,
): (Map[String, Int], Map[TemplateId, Int]) = {
val (syncAcs, lapiAcs) = get_active_contracts(ref, limit)
val groupedSync = syncAcs.toSeq
.map { x =>
x.swap
}
.groupBy(_._1)
.map(x => (x._1, x._2.length))
val groupedLapi = lapiAcs.toSeq
.map { x =>
x.swap
}
.groupBy(_._1)
.map(x => (x._1, x._2.length))
(groupedSync, groupedLapi)
}
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
/** Interface for output to the Console user.
*/
trait ConsoleOutput {
/** By default, commands should not output anything to the user. So use this only if it absolutely has to be.
*
* In particular:
* - If there is an error, then report this to the log file. The log file will also be displayed to the user.
* - If a command completes successfully, do not output anything.
* - If a command returns some `value`, then make the command return the value (instead of printing the value to the console).
* This allows the user to access the value programmatically.
* (Make sure that `value.toString` creates a readable representation.)
*/
def info(message: String): Unit
}
/** Logs directly to stdout and stderr.
*/
object StandardConsoleOutput extends ConsoleOutput {
override def info(message: String): Unit = Console.out.println(message)
}

View File

@ -0,0 +1,34 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
sealed trait FeatureFlag {
def configName: String
}
object FeatureFlag {
object Stable extends FeatureFlag {
val configName = "enabled-by-default"
override def toString: String = "Stable"
}
object Preview extends FeatureFlag {
val configName = "enable-preview-commands"
override def toString: String = "Preview"
}
object Repair extends FeatureFlag {
val configName = "enable-repair-commands"
override def toString: String = "Repair"
}
object Testing extends FeatureFlag {
val configName = "enable-testing-commands"
override def toString: String = "Testing"
}
lazy val all = Set(Stable, Preview, Repair, Testing)
}

View File

@ -0,0 +1,127 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import better.files.File
import com.digitalasset.canton.admin.api.client.commands.StatusAdminCommands
import com.digitalasset.canton.admin.api.client.data.CantonStatus
import com.digitalasset.canton.config.LocalNodeConfig
import com.digitalasset.canton.console.CommandErrors.CommandError
import com.digitalasset.canton.environment.Environment
import com.digitalasset.canton.health.admin.data.NodeStatus
import com.digitalasset.canton.health.admin.{data, v0}
import com.digitalasset.canton.metrics.MetricsSnapshot
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.version.ReleaseVersion
import io.circe.Encoder
import io.circe.syntax.*
import scala.annotation.nowarn
/** Generates a health dump zip file containing information about the current Canton process
* This is the core of the implementation of the HealthDump gRPC endpoint.
*/
trait HealthDumpGenerator[Status <: CantonStatus] {
def status(): Status
def environment: Environment
def grpcAdminCommandRunner: GrpcAdminCommandRunner
protected implicit val statusEncoder: Encoder[Status]
protected def getStatusForNode[S <: NodeStatus.Status](
nodeName: String,
nodeConfig: LocalNodeConfig,
deserializer: v0.NodeStatus.Status => ParsingResult[S],
): NodeStatus[S] = {
grpcAdminCommandRunner
.runCommand(
nodeName,
new StatusAdminCommands.GetStatus[S](deserializer),
nodeConfig.clientAdminApi,
None,
) match {
case CommandSuccessful(value) => value
case err: CommandError => data.NodeStatus.Failure(err.cause)
}
}
protected def statusMap[S <: NodeStatus.Status](
nodes: Map[String, LocalNodeConfig],
deserializer: v0.NodeStatus.Status => ParsingResult[S],
): Map[String, () => NodeStatus[S]] = {
nodes.map { case (nodeName, nodeConfig) =>
nodeName -> (() => getStatusForNode[S](nodeName, nodeConfig, deserializer))
}
}
@nowarn("cat=lint-byname-implicit") // https://github.com/scala/bug/issues/12072
def generateHealthDump(
outputFile: File,
extraFilesToZip: Seq[File] = Seq.empty,
): File = {
import io.circe.generic.auto.*
import CantonHealthAdministrationEncoders.*
final case class EnvironmentInfo(os: String, javaVersion: String)
final case class CantonDump(
releaseVersion: String,
environment: EnvironmentInfo,
config: String,
status: Status,
metrics: MetricsSnapshot,
traces: Map[Thread, Array[StackTraceElement]],
)
val javaVersion = System.getProperty("java.version")
val cantonVersion = ReleaseVersion.current.fullVersion
val env = EnvironmentInfo(sys.props("os.name"), javaVersion)
val metricsSnapshot = MetricsSnapshot(
environment.metricsFactory.registry,
environment.configuredOpenTelemetry.onDemandMetricsReader,
)
val config = environment.config.dumpString
val traces = {
import scala.jdk.CollectionConverters.*
Thread.getAllStackTraces.asScala.toMap
}
val dump = CantonDump(cantonVersion, env, config, status(), metricsSnapshot, traces)
val logFile =
File(
sys.env
.get("LOG_FILE_NAME")
.orElse(sys.props.get("LOG_FILE_NAME")) // This is set in Cli.installLogging
.getOrElse("log/canton.log")
)
val logLastErrorsFile = File(
sys.env
.get("LOG_LAST_ERRORS_FILE_NAME")
.orElse(sys.props.get("LOG_LAST_ERRORS_FILE_NAME"))
.getOrElse("log/canton_errors.log")
)
// This is a guess based on the default logback config as to what the rolling log files look like
// If we want to be more robust we'd have to access logback directly, extract the pattern from there, and use it to
// glob files.
val rollingLogs = logFile.siblings
.filter { f =>
f.name.contains(logFile.name) && f.extension.contains(".gz")
}
.toSeq
.sortBy(_.name)
.take(environment.config.monitoring.dumpNumRollingLogFiles.unwrap)
File.usingTemporaryFile("canton-dump-", ".json") { tmpFile =>
tmpFile.append(dump.asJson.spaces2)
val files = Iterator(logFile, logLastErrorsFile, tmpFile).filter(_.nonEmpty)
outputFile.zipIn(files ++ extraFilesToZip.iterator ++ rollingLogs)
}
outputFile
}
}

View File

@ -0,0 +1,349 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import cats.syntax.functor.*
import com.digitalasset.canton.version.ProtocolVersion
import scala.annotation.StaticAnnotation
import scala.reflect.ClassTag
import scala.reflect.runtime.universe as ru
/** User friendly help messages generator.
*/
object Help {
private val defaultTopLevelTopicStr = "Top-level Commands"
val defaultTopLevelTopic = Seq(defaultTopLevelTopicStr)
/** A short summary of the method (to be displayed in a list)
*
* Note that the annotation parser is also hard-coded to the default flag Stable
*/
final case class Summary(s: String, flag: FeatureFlag = FeatureFlag.Stable)
extends StaticAnnotation {
override def toString: String = s
}
/** A longer description of the method */
final case class Description(s: String) extends StaticAnnotation {
override def toString: String = s
}
/** Indicates that a command is only available for domain running at least the specified protocolVersion. */
final case class AvailableFrom(protocolVersion: ProtocolVersion) extends StaticAnnotation {
override def toString: String = protocolVersion.toString
}
/** A sequence of strings classifying the method breadcrumb style (e.g. Seq("Participant", "Diagnostics")).
* Used as headings in the displayed help.
*/
final case class Topic(t: Seq[String]) extends StaticAnnotation {
override def toString(): String = t.mkString(": ")
}
/** A tag to indicate nesting of items */
final case class Group(name: String) extends StaticAnnotation {
override def toString: String = name
}
final case class MethodSignature(argsWithTypes: Seq[(String, String)], retType: String) {
val argString = "(" + argsWithTypes.map(arg => s"${arg._1}: ${arg._2}").mkString(", ") + ")"
val retString = s": $retType"
override def toString(): String = argString + retString
def noUnits(): String =
(if (argsWithTypes.isEmpty) "" else argString) +
(if (retType == "Unit") "" else retString)
}
final case class Item(
name: String,
signature: Option[MethodSignature],
summary: Summary,
description: Description,
topic: Topic,
subItems: Seq[Item] = Seq.empty,
)
/** Generate help messages from an object instance using reflection, using only the given summaries.
*
* ARGUMENTS OF THE ANNOTATIONS MUST BE LITERALS (CONSTANTS) (e.g., Topic(topicVariable) does not work).
*
* All methods with a [[Summary]] annotation will be included. [[Description]] or [[Topic]]
* are also included if present, and are set to the empty string otherwise.
* We attempt to make as friendly as possible:
* - Unit types are not displayed
* - Empty argument lists are dropped
* - Implicits are hidden
* See corresponding tests for examples.
*/
def forInstance[T: ClassTag](
instance: T,
baseTopic: Seq[String] = Seq(),
scope: Set[FeatureFlag] = FeatureFlag.all,
): String = {
// type extractor
val items = getItems(instance, baseTopic, scope)
format(items: _*)
}
def forMethod[T: ClassTag](
instance: T,
methodName: String,
scope: Set[FeatureFlag] = FeatureFlag.all,
): String =
forMethod(getItems(instance, scope = scope), methodName)
def forMethod(items: Seq[Item], methodName: String): String = {
def expand(item: Item): Seq[(String, Item)] = {
(item.name, item) +: item.subItems.flatMap(expand).map { case (mn, itm) =>
(item.name + "." + mn, itm)
}
}
val expanded = items.flatMap(expand)
val matching = expanded.filter { case (itemName, _) => itemName == methodName }
if (matching.nonEmpty) {
matching
.map { case (itemName, item) =>
formatItem(item.copy(name = itemName))
}
.mkString(System.lineSeparator())
} else {
val similarItems = expanded.map(_._1).filter(_.contains(methodName)).sorted.take(10)
if (similarItems.isEmpty)
s"Error: method $methodName not found; check your spelling"
else {
s"Error: method $methodName not found; are you looking for one of the following?\n ${similarItems
.mkString("\n ")}"
}
}
}
def formatItem(item: Item): String = item match {
case Item(name, optSignature, summary, description, topic, group) =>
val sigString = optSignature.map(_.noUnits()).getOrElse("")
val text = if (description.s.nonEmpty) description.toString else summary.toString
Seq(name + sigString, text).mkString(System.lineSeparator)
}
private def extractItem(
mirror: ru.Mirror,
member: ru.Symbol,
baseTopic: Seq[String],
scope: Set[FeatureFlag],
): Option[Item] =
memberDescription(member)
.filter { case (summary, _, _, _) => scope.contains(summary.flag) }
.map { case (summary, description, topic, group) =>
val methodName = member.name.toString
val info = member.info
val name = methodName
val myTopic = Topic(baseTopic ++ topic.t ++ group.toList)
val summaries = member.typeSignature.members
.flatMap(s =>
extractItem(mirror, s, myTopic.t, scope).toList
) // filter to members that have `@Help.Summary` applied
val signature = Some(methodSignature(info))
val topicOrDefault =
if (summaries.isEmpty && topic.t.isEmpty && group.isEmpty)
Topic(baseTopic ++ defaultTopLevelTopic)
else myTopic
Item(name, signature, summary, description, topicOrDefault, summaries.toSeq)
}
def getItems[T: ClassTag](
instance: T,
baseTopic: Seq[String] = Seq(),
scope: Set[FeatureFlag] = FeatureFlag.all,
): Seq[Item] = {
val mirror = ru.runtimeMirror(getClass.getClassLoader)
val mirroredType = mirror.reflect(instance)
mirroredType.symbol.typeSignature.members
.flatMap(m => extractItem(mirror, m, baseTopic, scope).toList)
.toSeq
}
def flattenItem(path: Seq[String])(item: Item): Seq[Item] = {
val newPath = path :+ item.name
item.copy(name = newPath.mkString(".")) +: item.subItems.flatMap(flattenItem(newPath))
}
def flattenItemsForManual(items: Seq[Item]): Seq[Item] =
items
.flatMap(flattenItem(Seq()))
.filter(_.subItems.isEmpty)
// strip trailing default topic such that we don't have to add "Top-level Commands" to every group in the manual
.map { itm =>
itm.copy(
topic =
if (
itm.topic.t.lengthCompare(1) > 0 && itm.topic.t.lastOption
.contains(defaultTopLevelTopicStr)
)
Topic(itm.topic.t.take(itm.topic.t.length - 1))
else itm.topic
)
}
def getItemsFlattenedForManual[T: ClassTag](
instance: T,
baseTopic: Seq[String] = Seq(),
): Seq[Item] =
flattenItemsForManual(getItems(instance, baseTopic))
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private def methodSignature[T](typ: ru.Type): MethodSignature = {
val methodType = typ.asInstanceOf[ru.TypeApi]
def excludeImplicits(symbols: List[ru.Symbol]): List[ru.Symbol] =
symbols.filter(!_.isImplicit)
def until[U](p: U => Boolean, f: U => U)(x: U): U =
if (p(x)) x else until(p, f)(f(x))
val args =
excludeImplicits(methodType.paramLists.flatten).map(symb =>
(symb.name.toString, symb.typeSignature.toString)
)
// return types can contain implicit parameter lists; ensure that these are excluded
val returnType =
until[ru.Type](typ => !typ.paramLists.flatten.exists(_.isImplicit), _.resultType)(
methodType.resultType
)
MethodSignature(args, returnType.toString)
}
def fromObject[T: ClassTag](instance: T): Seq[Item] =
getItems(instance)
/** Format help for named items and their descriptions
* @param items Tuple of name and description
*/
def format(items: Item*): String = {
def underline(s: String) = s + System.lineSeparator + "-" * s.length + System.lineSeparator()
val grouped = items
.filter(_.subItems.nonEmpty)
.sortBy(_.name)
.map { case Item(name, signature, Summary(summary, flag), description, topic, _) =>
s"$name - $summary"
}
.toList
val topLevel = items
.filter(_.subItems.isEmpty)
.groupBy(_.topic)
.fmap(descs =>
descs
.sortBy(_.name) // sort alphabetically
.map { case Item(name, signature, Summary(summary, flag), description, topic, _) =>
s"$name - $summary"
}
.mkString(System.lineSeparator)
)
.toList
.sortWith { (x, y) =>
(x._1.t, y._1.t) match {
case (`defaultTopLevelTopic`, _) => true
case (_, `defaultTopLevelTopic`) => false
case (lft, rght) => lft.mkString(".") < rght.mkString(".")
}
}
.map({ case (tobj @ Topic(topic), h) =>
(if (topic.nonEmpty) underline(tobj.toString) else "") + h
})
.mkString(System.lineSeparator + System.lineSeparator)
if (grouped.nonEmpty) {
topLevel + System.lineSeparator + System.lineSeparator + underline("Command Groups") + grouped
.mkString(System.lineSeparator)
} else topLevel
}
private def memberDescription(
member: ru.Symbol
): Option[(Summary, Description, Topic, Option[String])] = {
(
member.annotations.map(fromAnnotation(_, summaryParser)).collect { case Some(s) => s },
member.annotations.map(fromAnnotation(_, descriptionParser)).collect { case Some(s) => s },
member.annotations.map(fromAnnotation(_, tagParser)).collect { case Some(s) => s },
member.annotations.map(fromAnnotation(_, groupParser)).collect { case Some(s) => s },
) match {
case (Nil, _, _, _) => None
case (summary :: _sums, l2, l3, g4) =>
Some(
(
summary,
l2.headOption.getOrElse(Description("")),
l3.headOption.getOrElse(Topic(Seq())),
g4.headOption.map(_.name),
)
)
}
}
/** The following definitions (fromAnnotation and Xparser) are quite nasty.
* They hackily reconstruct the Scala values from annotations, which contain ASTs.
* It's possible to use a reflection Toolbox to eval the annotations, but this is very slow (as it entails compiling the ASTs
* from the annotations) and results in large latencies for the help command.
*/
private def fromAnnotation[T: ru.TypeTag](
annotation: ru.Annotation,
parser: ru.Tree => T,
): Option[T] = {
if (annotation.tree.tpe.typeSymbol == ru.typeOf[T].typeSymbol) {
Some(parser(annotation.tree))
} else None
}
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private def grabStringTag(tree: ru.Tree): String =
tree
.children(1)
.asInstanceOf[ru.Literal]
.value
.value
.asInstanceOf[String]
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private def summaryParser(tree: ru.Tree): Summary = {
def grabFeatureFlagFromSummary(tree: ru.Tree): FeatureFlag =
if (tree.children.lengthCompare(2) > 0) {
val tmp = tree.children(2).asInstanceOf[ru.Select]
if (tmp.symbol.isModule) {
reflect.runtime.currentMirror
.reflectModule(tmp.symbol.asModule)
.instance
.asInstanceOf[FeatureFlag]
} else FeatureFlag.Stable
} else FeatureFlag.Stable
Summary(grabStringTag(tree), grabFeatureFlagFromSummary(tree))
}
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private def descriptionParser(tree: ru.Tree): Description = {
try {
Description(grabStringTag(tree).stripMargin)
} catch {
case x: RuntimeException =>
// leave a comment for the poor developer that might run into the same issue ...
println(
"Failed to process description (description needs to be a string. i.e. don't apply stripmargin here ...): " + tree.toString
)
throw x
}
}
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private def tagParser(tree: ru.Tree): Topic = {
val args = tree
.children(1)
.children
.drop(1)
.map(l => l.asInstanceOf[ru.Literal].value.value.asInstanceOf[String])
Topic(args)
}
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private def groupParser(tree: ru.Tree): Group = Group(grabStringTag(tree))
}

View File

@ -0,0 +1,23 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
/** Implementors will have a `help` method available that will be callable from the Console.
* Implementors should annotate appropriate methods with `@Help.Summary` to have them included.
*/
trait Helpful {
def help()(implicit consoleEnvironment: ConsoleEnvironment): Unit = {
val featureSet = consoleEnvironment.featureSet
consoleEnvironment.consoleOutput.info(Help.forInstance(this, scope = featureSet))
}
@Help.Summary("Help for specific commands (use help() or help(\"method\") for more information)")
@Help.Topic(Seq("Top-level Commands"))
def help(methodName: String)(implicit consoleEnvironment: ConsoleEnvironment): Unit =
consoleEnvironment.consoleOutput.info(
Help.forMethod(this, methodName, scope = consoleEnvironment.featureSet)
)
}

View File

@ -0,0 +1,810 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import com.daml.lf.data.Ref.PackageId
import com.digitalasset.canton.*
import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand
import com.digitalasset.canton.config.RequireTypes.Port
import com.digitalasset.canton.config.*
import com.digitalasset.canton.console.CommandErrors.NodeNotStarted
import com.digitalasset.canton.console.commands.*
import com.digitalasset.canton.crypto.Crypto
import com.digitalasset.canton.domain.config.RemoteDomainConfig
import com.digitalasset.canton.domain.{Domain, DomainNodeBootstrap}
import com.digitalasset.canton.environment.*
import com.digitalasset.canton.health.admin.data.{DomainStatus, NodeStatus, ParticipantStatus}
import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting}
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging, TracedLogger}
import com.digitalasset.canton.participant.config.{
BaseParticipantConfig,
LocalParticipantConfig,
RemoteParticipantConfig,
}
import com.digitalasset.canton.participant.domain.DomainConnectionConfig
import com.digitalasset.canton.participant.{
ParticipantNode,
ParticipantNodeBootstrapX,
ParticipantNodeCommon,
ParticipantNodeX,
}
import com.digitalasset.canton.sequencing.{SequencerConnection, SequencerConnections}
import com.digitalasset.canton.topology.{DomainId, NodeIdentity, ParticipantId}
import com.digitalasset.canton.tracing.NoTracing
import com.digitalasset.canton.util.ErrorUtil
import scala.concurrent.ExecutionContext
import scala.util.hashing.MurmurHash3
trait InstanceReferenceCommon
extends AdminCommandRunner
with Helpful
with NamedLogging
with FeatureFlagFilter
with PrettyPrinting {
val name: String
protected val instanceType: String
protected[canton] def executionContext: ExecutionContext
override def pretty: Pretty[InstanceReferenceCommon] =
prettyOfString(inst => show"${inst.instanceType.unquoted} ${inst.name.singleQuoted}")
val consoleEnvironment: ConsoleEnvironment
override protected[console] def tracedLogger: TracedLogger = logger
override def hashCode(): Int = {
val init = this.getClass.hashCode()
val t1 = MurmurHash3.mix(init, consoleEnvironment.hashCode())
val t2 = MurmurHash3.mix(t1, name.hashCode)
t2
}
// this is just testing, because the cached values should remain unchanged in operation
@Help.Summary("Clear locally cached variables", FeatureFlag.Testing)
@Help.Description(
"Some commands cache values on the client side. Use this command to explicitly clear the caches of these values."
)
def clear_cache(): Unit = {
topology.clearCache()
}
type Status <: NodeStatus.Status
def id: NodeIdentity
def health: HealthAdministrationCommon[Status]
def keys: KeyAdministrationGroup
def topology: TopologyAdministrationGroupCommon
}
/** Reference to "Old" daml 2.x nodes have:
* - parties admin commands
* - "old" topology admin commands based on "old" TopologyChangeOp
*/
trait InstanceReference extends InstanceReferenceCommon {
def parties: PartiesAdministrationGroup
override def topology: TopologyAdministrationGroup
}
/** InstanceReferenceX with different topology administration x
*/
trait InstanceReferenceX extends InstanceReferenceCommon {
override def topology: TopologyAdministrationGroupX
private lazy val trafficControl_ =
new TrafficControlAdministrationGroup(
this,
topology,
this,
consoleEnvironment,
loggerFactory,
)
@Help.Summary("Traffic control related commands")
@Help.Group("Traffic")
def traffic_control: TrafficControlAdministrationGroup = trafficControl_
}
/** Pointer for a potentially running instance by instance type (domain/participant) and its id.
* These methods define the REPL interface for these instances (e.g. participant1 start)
*/
trait LocalInstanceReferenceCommon extends InstanceReferenceCommon with NoTracing {
val name: String
val consoleEnvironment: ConsoleEnvironment
private[console] val nodes: Nodes[CantonNode, CantonNodeBootstrap[CantonNode]]
@Help.Summary("Database related operations")
@Help.Group("Database")
object db extends Helpful {
@Help.Summary("Migrates the instance's database if using a database storage")
def migrate(): Unit = consoleEnvironment.run(migrateDbCommand())
@Help.Summary(
"Only use when advised - repairs the database migration of the instance's database"
)
@Help.Description(
"""In some rare cases, we change already applied database migration files in a new release and the repair
|command resets the checksums we use to ensure that in general already applied migration files have not been changed.
|You should only use `db.repair_migration` when advised and otherwise use it at your own risk - in the worst case running
|it may lead to data corruption when an incompatible database migration (one that should be rejected because
|the already applied database migration files have changed) is subsequently falsely applied.
|"""
)
def repair_migration(force: Boolean = false): Unit =
consoleEnvironment.run(repairMigrationCommand(force))
}
@Help.Summary("Start the instance")
def start(): Unit = consoleEnvironment.run(startCommand())
@Help.Summary("Stop the instance")
def stop(): Unit = consoleEnvironment.run(stopCommand())
@Help.Summary("Check if the local instance is running")
def is_running: Boolean = nodes.isRunning(name)
@Help.Summary("Check if the local instance is running and is fully initialized")
def is_initialized: Boolean = nodes.getRunning(name).exists(_.isInitialized)
@Help.Summary("Config of node instance")
def config: LocalNodeConfig
@Help.Summary("Manage public and secret keys")
@Help.Group("Keys")
override def keys: LocalKeyAdministrationGroup = _keys
private val _keys =
new LocalKeyAdministrationGroup(this, this, consoleEnvironment, crypto, loggerFactory)(
executionContext
)
private[console] def migrateDbCommand(): ConsoleCommandResult[Unit] =
migrateInstanceDb().toResult(_.message, _ => ())
private[console] def repairMigrationCommand(force: Boolean): ConsoleCommandResult[Unit] =
repairMigrationOfInstance(force).toResult(_.message, _ => ())
private[console] def startCommand(): ConsoleCommandResult[Unit] =
startInstance()
.toResult({
case m: PendingDatabaseMigration =>
s"${m.message} Please run `${m.name}.db.migrate` to apply pending migrations"
case m => m.message
})
private[console] def stopCommand(): ConsoleCommandResult[Unit] =
try {
stopInstance().toResult(_.message)
} finally {
ErrorUtil.withThrowableLogging(clear_cache())
}
protected def migrateInstanceDb(): Either[StartupError, _] = nodes.migrateDatabase(name)
protected def repairMigrationOfInstance(force: Boolean): Either[StartupError, Unit] = {
Either
.cond(force, (), DidntUseForceOnRepairMigration(name))
.flatMap(_ => nodes.repairDatabaseMigration(name))
}
protected def startInstance(): Either[StartupError, Unit] =
nodes.startAndWait(name)
protected def stopInstance(): Either[ShutdownError, Unit] = nodes.stopAndWait(name)
protected[canton] def crypto: Crypto
protected def runCommandIfRunning[Result](
runner: => ConsoleCommandResult[Result]
): ConsoleCommandResult[Result] =
if (is_running)
runner
else
NodeNotStarted.ErrorCanton(this)
override protected[console] def adminCommand[Result](
grpcCommand: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result] = {
runCommandIfRunning(
consoleEnvironment.grpcAdminCommandRunner
.runCommand(name, grpcCommand, config.clientAdminApi, None)
)
}
}
trait LocalInstanceReference extends LocalInstanceReferenceCommon with InstanceReference
trait LocalInstanceReferenceX extends LocalInstanceReferenceCommon with InstanceReferenceX
trait RemoteInstanceReference extends InstanceReferenceCommon {
@Help.Summary("Manage public and secret keys")
@Help.Group("Keys")
override val keys: KeyAdministrationGroup =
new KeyAdministrationGroup(this, this, consoleEnvironment, loggerFactory)
}
trait GrpcRemoteInstanceReference extends RemoteInstanceReference {
def config: NodeConfig
override protected[console] def adminCommand[Result](
grpcCommand: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result] =
consoleEnvironment.grpcAdminCommandRunner.runCommand(
name,
grpcCommand,
config.clientAdminApi,
None,
)
}
object DomainReference {
val InstanceType = "Domain"
}
trait DomainReference
extends InstanceReference
with DomainAdministration
with InstanceReferenceWithSequencer {
val consoleEnvironment: ConsoleEnvironment
val name: String
override protected val instanceType: String = DomainReference.InstanceType
override type Status = DomainStatus
@Help.Summary("Health and diagnostic related commands")
@Help.Group("Health")
override def health =
new HealthAdministration[DomainStatus](
this,
consoleEnvironment,
DomainStatus.fromProtoV0,
)
@Help.Summary(
"Yields the globally unique id of this domain. " +
"Throws an exception, if the id has not yet been allocated (e.g., the domain has not yet been started)."
)
def id: DomainId = topology.idHelper(DomainId(_))
private lazy val topology_ =
new TopologyAdministrationGroup(
this,
this.health.status.successOption.map(_.topologyQueue),
consoleEnvironment,
loggerFactory,
)
@Help.Summary("Topology management related commands")
@Help.Group("Topology")
@Help.Description("This group contains access to the full set of topology management commands.")
override def topology: TopologyAdministrationGroup = topology_
override protected val loggerFactory: NamedLoggerFactory = NamedLoggerFactory("domain", name)
override def equals(obj: Any): Boolean = {
obj match {
case x: DomainReference => x.consoleEnvironment == consoleEnvironment && x.name == name
case _ => false
}
}
@Help.Summary("Inspect configured parties")
@Help.Group("Parties")
override def parties: PartiesAdministrationGroup = partiesGroup
// above command needs to be def such that `Help` works.
lazy private val partiesGroup = new PartiesAdministrationGroup(this, consoleEnvironment)
private lazy val sequencer_ =
new SequencerAdministrationGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Manage the sequencer")
@Help.Group("Sequencer")
override def sequencer: SequencerAdministrationGroup = sequencer_
private lazy val mediator_ =
new MediatorAdministrationGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Manage the mediator")
@Help.Group("Mediator")
def mediator: MediatorAdministrationGroup = mediator_
@Help.Summary(
"Yields a domain connection config with default values except for the domain alias and the sequencer connection. " +
"May throw an exception if the domain alias or sequencer connection is misconfigured."
)
def defaultDomainConnection: DomainConnectionConfig =
DomainConnectionConfig(
DomainAlias.tryCreate(name),
SequencerConnections.single(sequencerConnection),
)
}
trait RemoteDomainReference extends DomainReference with GrpcRemoteInstanceReference {
val consoleEnvironment: ConsoleEnvironment
val name: String
@Help.Summary("Returns the remote domain configuration")
def config: RemoteDomainConfig =
consoleEnvironment.environment.config.remoteDomainsByString(name)
override def sequencerConnection: SequencerConnection =
config.publicApi.toConnection
.fold(
err => sys.error(s"Domain $name has invalid sequencer connection config: $err"),
identity,
)
}
trait CommunityDomainReference {
this: DomainReference =>
}
class CommunityRemoteDomainReference(val consoleEnvironment: ConsoleEnvironment, val name: String)
extends DomainReference
with CommunityDomainReference
with RemoteDomainReference {
override protected[canton] def executionContext: ExecutionContext =
consoleEnvironment.environment.executionContext
}
trait InstanceReferenceWithSequencerConnection extends InstanceReferenceCommon {
def sequencerConnection: SequencerConnection
}
trait InstanceReferenceWithSequencer extends InstanceReferenceWithSequencerConnection {
def sequencer: SequencerAdministrationGroup
}
trait LocalDomainReference
extends DomainReference
with BaseInspection[Domain]
with LocalInstanceReference {
override private[console] val nodes = consoleEnvironment.environment.domains
@Help.Summary("Returns the domain configuration")
def config: consoleEnvironment.environment.config.DomainConfigType =
consoleEnvironment.environment.config.domainsByString(name)
override def sequencerConnection: SequencerConnection =
config.sequencerConnectionConfig.toConnection
.fold(
err => sys.error(s"Domain $name has invalid sequencer connection config: $err"),
identity,
)
override protected[console] def runningNode: Option[DomainNodeBootstrap] =
consoleEnvironment.environment.domains.getRunning(name)
override protected[console] def startingNode: Option[DomainNodeBootstrap] =
consoleEnvironment.environment.domains.getStarting(name)
}
class CommunityLocalDomainReference(
override val consoleEnvironment: ConsoleEnvironment,
val name: String,
override protected[canton] val executionContext: ExecutionContext,
) extends DomainReference
with CommunityDomainReference
with LocalDomainReference
/** Bare, Canton agnostic parts of the ledger-api client
*
* This implementation allows to access any kind of ledger-api client, which does not need to be Canton based.
* However, this comes at some cost, as some of the synchronization between nodes during transaction submission
* is not supported
*
* @param hostname the hostname of the ledger api server
* @param port the port of the ledger api server
* @param tls the tls config to use on the client
* @param token the jwt token to use on the client
*/
class ExternalLedgerApiClient(
hostname: String,
port: Port,
tls: Option[TlsClientConfig],
val token: Option[String] = None,
)(implicit val consoleEnvironment: ConsoleEnvironment)
extends BaseLedgerApiAdministration
with LedgerApiCommandRunner
with FeatureFlagFilter
with NamedLogging {
override protected val name: String = s"$hostname:${port.unwrap}"
override val loggerFactory: NamedLoggerFactory =
consoleEnvironment.environment.loggerFactory.append("client", name)
override protected def domainOfTransaction(transactionId: String): DomainId =
throw new NotImplementedError("domain_of is not implemented for external ledger api clients")
override protected[console] def ledgerApiCommand[Result](
command: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result] =
consoleEnvironment.grpcAdminCommandRunner
.runCommand("sourceLedger", command, ClientConfig(hostname, port, tls), token)
override protected def optionallyAwait[Tx](
tx: Tx,
txId: String,
optTimeout: Option[NonNegativeDuration],
): Tx = tx
}
object ExternalLedgerApiClient {
def forReference(participant: LocalParticipantReference, token: String)(implicit
env: ConsoleEnvironment
): ExternalLedgerApiClient = {
val cc = participant.config.ledgerApi.clientConfig
new ExternalLedgerApiClient(
cc.address,
cc.port,
cc.tls,
Some(token),
)
}
}
object ParticipantReference {
val InstanceType = "Participant"
}
sealed trait ParticipantReferenceCommon
extends ConsoleCommandGroup
with ParticipantAdministration
with LedgerApiAdministration
with LedgerApiCommandRunner
with AdminCommandRunner
with InstanceReferenceCommon {
override type Status = ParticipantStatus
override protected val loggerFactory: NamedLoggerFactory =
consoleEnvironment.environment.loggerFactory.append("participant", name)
@Help.Summary(
"Yields the globally unique id of this participant. " +
"Throws an exception, if the id has not yet been allocated (e.g., the participant has not yet been started)."
)
override def id: ParticipantId = topology.idHelper(ParticipantId(_))
def config: BaseParticipantConfig
@Help.Summary("Commands used for development and testing", FeatureFlag.Testing)
@Help.Group("Testing")
def testing: ParticipantTestingGroup
@Help.Summary("Commands to pruning the archive of the ledger", FeatureFlag.Preview)
@Help.Group("Ledger Pruning")
def pruning: ParticipantPruningAdministrationGroup = pruning_
private lazy val pruning_ =
new ParticipantPruningAdministrationGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Manage participant replication")
@Help.Group("Replication")
def replication: ParticipantReplicationAdministrationGroup = replicationGroup
lazy private val replicationGroup =
new ParticipantReplicationAdministrationGroup(this, consoleEnvironment)
@Help.Summary("Commands to repair the participant contract state", FeatureFlag.Repair)
@Help.Group("Repair")
def repair: ParticipantRepairAdministration
override def health
: HealthAdministrationCommon[ParticipantStatus] & ParticipantHealthAdministrationCommon
}
abstract class ParticipantReference(
override val consoleEnvironment: ConsoleEnvironment,
val name: String,
) extends ParticipantReferenceCommon
with InstanceReference {
protected def runner: AdminCommandRunner = this
override protected val instanceType: String = ParticipantReference.InstanceType
@Help.Summary("Health and diagnostic related commands")
@Help.Group("Health")
override def health: ParticipantHealthAdministration =
new ParticipantHealthAdministration(this, consoleEnvironment, loggerFactory)
@Help.Summary("Inspect and manage parties")
@Help.Group("Parties")
def parties: ParticipantPartiesAdministrationGroup
@Help.Summary(
"Yields the globally unique id of this participant. " +
"Throws an exception, if the id has not yet been allocated (e.g., the participant has not yet been started)."
)
override def id: ParticipantId = topology.idHelper(ParticipantId(_))
private lazy val topology_ =
new TopologyAdministrationGroup(
this,
health.status.successOption.map(_.topologyQueue),
consoleEnvironment,
loggerFactory,
)
@Help.Summary("Topology management related commands")
@Help.Group("Topology")
@Help.Description("This group contains access to the full set of topology management commands.")
def topology: TopologyAdministrationGroup = topology_
override protected def vettedPackagesOfParticipant(): Set[PackageId] = topology.vetted_packages
.list(filterStore = "Authorized", filterParticipant = id.filterString)
.flatMap(_.item.packageIds)
.toSet
override protected def participantIsActiveOnDomain(
domainId: DomainId,
participantId: ParticipantId,
): Boolean = topology.participant_domain_states.active(domainId, participantId)
}
sealed trait RemoteParticipantReferenceCommon
extends LedgerApiCommandRunner
with ParticipantReferenceCommon {
def config: RemoteParticipantConfig
override protected[console] def ledgerApiCommand[Result](
command: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result] =
consoleEnvironment.grpcAdminCommandRunner.runCommand(
name,
command,
config.clientLedgerApi,
config.token,
)
override protected[console] def token: Option[String] = config.token
private lazy val testing_ = new ParticipantTestingGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Commands used for development and testing", FeatureFlag.Testing)
@Help.Group("Testing")
override def testing: ParticipantTestingGroup = testing_
private lazy val repair_ =
new ParticipantRepairAdministration(consoleEnvironment, this, loggerFactory)
@Help.Summary("Commands to repair the participant contract state", FeatureFlag.Repair)
@Help.Group("Repair")
def repair: ParticipantRepairAdministration = repair_
}
class RemoteParticipantReference(environment: ConsoleEnvironment, override val name: String)
extends ParticipantReference(environment, name)
with GrpcRemoteInstanceReference
with RemoteParticipantReferenceCommon {
@Help.Summary("Inspect and manage parties")
@Help.Group("Parties")
def parties: ParticipantPartiesAdministrationGroup = partiesGroup
// above command needs to be def such that `Help` works.
lazy private val partiesGroup =
new ParticipantPartiesAdministrationGroup(id, this, consoleEnvironment)
@Help.Summary("Return remote participant config")
def config: RemoteParticipantConfig =
consoleEnvironment.environment.config.remoteParticipantsByString(name)
override def equals(obj: Any): Boolean = {
obj match {
case x: RemoteParticipantReference =>
x.consoleEnvironment == consoleEnvironment && x.name == name
case _ => false
}
}
}
sealed trait LocalParticipantReferenceCommon
extends LedgerApiCommandRunner
with ParticipantReferenceCommon
with LocalInstanceReferenceCommon {
def config: LocalParticipantConfig
def adminToken: Option[String]
override protected[console] def ledgerApiCommand[Result](
command: GrpcAdminCommand[_, _, Result]
): ConsoleCommandResult[Result] =
runCommandIfRunning(
consoleEnvironment.grpcAdminCommandRunner
.runCommand(name, command, config.clientLedgerApi, adminToken)
)
override protected[console] def token: Option[String] = adminToken
@Help.Summary("Commands to repair the local participant contract state", FeatureFlag.Repair)
@Help.Group("Repair")
def repair: LocalParticipantRepairAdministration
}
class LocalParticipantReference(
override val consoleEnvironment: ConsoleEnvironment,
name: String,
) extends ParticipantReference(consoleEnvironment, name)
with LocalParticipantReferenceCommon
with LocalInstanceReference
with BaseInspection[ParticipantNode] {
override private[console] val nodes = consoleEnvironment.environment.participants
@Help.Summary("Return participant config")
def config: LocalParticipantConfig =
consoleEnvironment.environment.config.participantsByString(name)
private lazy val testing_ =
new LocalParticipantTestingGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Commands used for development and testing", FeatureFlag.Testing)
override def testing: LocalParticipantTestingGroup = testing_
private lazy val commitments_ =
new LocalCommitmentsAdministrationGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Commands to inspect and extract bilateral commitments", FeatureFlag.Preview)
@Help.Group("Commitments")
def commitments: LocalCommitmentsAdministrationGroup = commitments_
private lazy val repair_ =
new LocalParticipantRepairAdministration(consoleEnvironment, this, loggerFactory) {
override protected def access[T](handler: ParticipantNodeCommon => T): T =
LocalParticipantReference.this.access(handler)
}
@Help.Summary("Commands to repair the local participant contract state", FeatureFlag.Repair)
@Help.Group("Repair")
def repair: LocalParticipantRepairAdministration = repair_
@Help.Summary("Inspect and manage parties")
@Help.Group("Parties")
override def parties: LocalParticipantPartiesAdministrationGroup = partiesGroup
// above command needs to be def such that `Help` works.
lazy private val partiesGroup =
new LocalParticipantPartiesAdministrationGroup(this, this, consoleEnvironment, loggerFactory)
/** secret, not publicly documented way to get the admin token */
def adminToken: Option[String] = underlying.map(_.adminToken.secret)
override def equals(obj: Any): Boolean = {
obj match {
case x: LocalParticipantReference =>
x.consoleEnvironment == consoleEnvironment && x.name == name
case _ => false
}
}
override def runningNode: Option[CantonNodeBootstrap[ParticipantNode]] =
consoleEnvironment.environment.participants.getRunning(name)
override def startingNode: Option[CantonNodeBootstrap[ParticipantNode]] =
consoleEnvironment.environment.participants.getStarting(name)
}
abstract class ParticipantReferenceX(
override val consoleEnvironment: ConsoleEnvironment,
val name: String,
) extends ParticipantReferenceCommon
with InstanceReferenceX {
override protected val instanceType: String = ParticipantReferenceX.InstanceType
override protected def runner: AdminCommandRunner = this
@Help.Summary("Health and diagnostic related commands")
@Help.Group("Health")
override def health: ParticipantHealthAdministrationX =
new ParticipantHealthAdministrationX(this, consoleEnvironment, loggerFactory)
@Help.Summary("Inspect and manage parties")
@Help.Group("Parties")
def parties: ParticipantPartiesAdministrationGroupX
private lazy val topology_ =
new TopologyAdministrationGroupX(
this,
health.status.successOption.map(_.topologyQueue),
consoleEnvironment,
loggerFactory,
)
@Help.Summary("Topology management related commands")
@Help.Group("Topology")
@Help.Description("This group contains access to the full set of topology management commands.")
override def topology: TopologyAdministrationGroupX = topology_
override protected def vettedPackagesOfParticipant(): Set[PackageId] = topology.vetted_packages
.list(filterStore = "Authorized", filterParticipant = id.filterString)
.flatMap(_.item.packageIds)
.toSet
override protected def participantIsActiveOnDomain(
domainId: DomainId,
participantId: ParticipantId,
): Boolean = topology.domain_trust_certificates.active(domainId, participantId)
}
object ParticipantReferenceX {
val InstanceType = "ParticipantX"
}
class RemoteParticipantReferenceX(environment: ConsoleEnvironment, override val name: String)
extends ParticipantReferenceX(environment, name)
with GrpcRemoteInstanceReference
with RemoteParticipantReferenceCommon {
@Help.Summary("Inspect and manage parties")
@Help.Group("Parties")
override def parties: ParticipantPartiesAdministrationGroupX = partiesGroup
// above command needs to be def such that `Help` works.
lazy private val partiesGroup =
new ParticipantPartiesAdministrationGroupX(id, this, consoleEnvironment)
@Help.Summary("Return remote participant config")
def config: RemoteParticipantConfig =
consoleEnvironment.environment.config.remoteParticipantsByStringX(name)
override def equals(obj: Any): Boolean = {
obj match {
case x: RemoteParticipantReference =>
x.consoleEnvironment == consoleEnvironment && x.name == name
case _ => false
}
}
}
class LocalParticipantReferenceX(
override val consoleEnvironment: ConsoleEnvironment,
name: String,
) extends ParticipantReferenceX(consoleEnvironment, name)
with LocalParticipantReferenceCommon
with LocalInstanceReferenceX
with BaseInspection[ParticipantNodeX] {
override private[console] val nodes = consoleEnvironment.environment.participantsX
@Help.Summary("Return participant config")
def config: LocalParticipantConfig =
consoleEnvironment.environment.config.participantsByStringX(name)
override def runningNode: Option[ParticipantNodeBootstrapX] =
consoleEnvironment.environment.participantsX.getRunning(name)
override def startingNode: Option[ParticipantNodeBootstrapX] =
consoleEnvironment.environment.participantsX.getStarting(name)
/** secret, not publicly documented way to get the admin token */
def adminToken: Option[String] = underlying.map(_.adminToken.secret)
// TODO(#14048) these are "remote" groups. the normal participant node has "local" versions.
// but rather than keeping this, we should make local == remote and add local methods separately
@Help.Summary("Inspect and manage parties")
@Help.Group("Parties")
def parties: LocalParticipantPartiesAdministrationGroupX = partiesGroup
// above command needs to be def such that `Help` works.
lazy private val partiesGroup =
new LocalParticipantPartiesAdministrationGroupX(this, this, consoleEnvironment, loggerFactory)
private lazy val testing_ = new ParticipantTestingGroup(this, consoleEnvironment, loggerFactory)
@Help.Summary("Commands used for development and testing", FeatureFlag.Testing)
@Help.Group("Testing")
override def testing: ParticipantTestingGroup = testing_
private lazy val repair_ =
new LocalParticipantRepairAdministration(consoleEnvironment, this, loggerFactory) {
override protected def access[T](handler: ParticipantNodeCommon => T): T =
LocalParticipantReferenceX.this.access(handler)
}
@Help.Summary("Commands to repair the local participant contract state", FeatureFlag.Repair)
@Help.Group("Repair")
def repair: LocalParticipantRepairAdministration = repair_
}

View File

@ -0,0 +1,75 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import com.digitalasset.canton.environment.{CantonNode, CantonNodeBootstrap, Nodes}
import com.digitalasset.canton.tracing.TraceContext
/** Aliases to manage a sequence of instances in a REPL environment
*/
trait LocalInstancesExtensions extends Helpful {
import ConsoleCommandResult.runAll
def instances: Seq[LocalInstanceReferenceCommon]
@Help.Summary("Database management related operations")
@Help.Group("Database")
object db extends Helpful {
@Help.Summary("Migrate all databases")
def migrate()(implicit consoleEnvironment: ConsoleEnvironment): Unit = {
val _ = runAll(instances.sorted(consoleEnvironment.startupOrdering)) {
_.migrateDbCommand()
}
}
@Help.Summary("Only use when advised - repair the database migration of all nodes")
@Help.Description(
"""In some rare cases, we change already applied database migration files in a new release and the repair
|command resets the checksums we use to ensure that in general already applied migration files have not been changed.
|You should only use `db.repair_migration` when advised and otherwise use it at your own risk - in the worst case running
|it may lead to data corruption when an incompatible database migration (one that should be rejected because
|the already applied database migration files have changed) is subsequently falsely applied.
|"""
)
def repair_migration(
force: Boolean = false
)(implicit consoleEnvironment: ConsoleEnvironment): Unit = {
val _ = runAll(instances.sorted(consoleEnvironment.startupOrdering)) {
_.repairMigrationCommand(force)
}
}
}
private def runOnAllInstances[T](
cmd: Seq[(String, Nodes[CantonNode, CantonNodeBootstrap[CantonNode]])] => Either[T, Unit]
)(implicit consoleEnvironment: ConsoleEnvironment): Unit =
consoleEnvironment.runE(cmd(instances.map(x => (x.name, x.nodes))))
@Help.Summary("Start all")
def start()(implicit consoleEnvironment: ConsoleEnvironment): Unit =
TraceContext.withNewTraceContext { implicit traceContext =>
runOnAllInstances(consoleEnvironment.environment.startNodes(_))
}
@Help.Summary("Stop all")
def stop()(implicit consoleEnvironment: ConsoleEnvironment): Unit =
TraceContext.withNewTraceContext { implicit traceContext =>
runOnAllInstances(consoleEnvironment.environment.stopNodes(_))
}
}
object LocalInstancesExtensions {
class Impl(val instances: Seq[LocalInstanceReferenceCommon]) extends LocalInstancesExtensions {}
}
class LocalDomainReferencesExtensions(domains: Seq[LocalDomainReference])
extends LocalInstancesExtensions {
override def instances: Seq[LocalDomainReference] = domains
}

View File

@ -0,0 +1,146 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import cats.syntax.traverse.*
import com.daml.nonempty.NonEmpty
import com.digitalasset.canton.config.NonNegativeDuration
import com.digitalasset.canton.console.commands.ParticipantCommands
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.participant.domain.DomainConnectionConfig
import com.digitalasset.canton.{DomainAlias, SequencerAlias}
class ParticipantReferencesExtensions(participants: Seq[ParticipantReferenceCommon])(implicit
override val consoleEnvironment: ConsoleEnvironment
) extends Helpful
with NamedLogging
with FeatureFlagFilter {
protected override def loggerFactory: NamedLoggerFactory =
consoleEnvironment.environment.loggerFactory
@Help.Summary("Manage dars on several participants at once")
@Help.Group("DAR Management")
object dars extends Helpful {
@Help.Summary("Upload DARs to participants")
@Help.Description(
"""If vetAllPackages is true, the participants will vet the package on all domains they are registered.
If synchronizeVetting is true, the command will block until the package vetting transaction has been registered with all connected domains."""
)
def upload(
darPath: String,
vetAllPackages: Boolean = true,
synchronizeVetting: Boolean = true,
): Map[ParticipantReferenceCommon, String] = {
val res = ConsoleCommandResult.runAll(participants)(
ParticipantCommands.dars
.upload(
_,
darPath,
vetAllPackages = vetAllPackages,
synchronizeVetting = synchronizeVetting,
logger,
)
)
if (synchronizeVetting && vetAllPackages) {
participants.foreach(_.packages.synchronize_vetting())
}
res
}
}
@Help.Summary("Manage domain connections on several participants at once")
@Help.Group("Domains")
object domains extends Helpful {
@Help.Summary("Disconnect from domain")
def disconnect(alias: DomainAlias): Unit =
ConsoleCommandResult
.runAll(participants)(ParticipantCommands.domains.disconnect(_, alias))
.discard
@Help.Summary("Disconnect from a local domain")
def disconnect_local(domain: LocalDomainReference): Unit =
ConsoleCommandResult
.runAll(participants)(
ParticipantCommands.domains.disconnect(_, DomainAlias.tryCreate(domain.name))
)
.discard
@Help.Summary("Reconnect to domain")
@Help.Description(
"If retry is set to true (default), the command will return after the first attempt, but keep on trying in the background."
)
def reconnect(alias: DomainAlias, retry: Boolean = true): Unit =
ConsoleCommandResult
.runAll(participants)(
ParticipantCommands.domains.reconnect(_, alias, retry)
)
.discard
@Help.Summary("Reconnect to all domains for which `manualStart` = false")
@Help.Description(
"""If ignoreFailures is set to true (default), the reconnect all will succeed even if some domains are offline.
| The participants will continue attempting to establish a domain connection."""
)
def reconnect_all(ignoreFailures: Boolean = true): Unit = {
val _ = ConsoleCommandResult.runAll(participants)(
ParticipantCommands.domains.reconnect_all(_, ignoreFailures = ignoreFailures)
)
}
@Help.Summary("Disconnect from all connected domains")
def disconnect_all(): Unit =
ConsoleCommandResult
.runAll(participants) { p =>
ConsoleCommandResult.fromEither(for {
connected <- ParticipantCommands.domains.list_connected(p).toEither
_ <- connected
.traverse(d => ParticipantCommands.domains.disconnect(p, d.domainAlias).toEither)
} yield ())
}
.discard
@Help.Summary("Register and potentially connect to domain")
def register(config: DomainConnectionConfig): Unit =
ConsoleCommandResult
.runAll(participants)(ParticipantCommands.domains.register(_, config))
.discard
@Help.Summary("Register and potentially connect to new local domain")
@Help.Description("""
The arguments are:
domain - A local domain or sequencer reference
manualConnect - Whether this connection should be handled manually and also excluded from automatic re-connect.
synchronize - A timeout duration indicating how long to wait for all topology changes to have been effected on all local nodes.
""")
def connect_local(
domain: InstanceReferenceWithSequencerConnection,
manualConnect: Boolean = false,
alias: Option[DomainAlias] = None,
synchronize: Option[NonNegativeDuration] = Some(
consoleEnvironment.commandTimeouts.bounded
),
): Unit = {
val config =
ParticipantCommands.domains.referenceToConfig(
NonEmpty.mk(Seq, SequencerAlias.Default -> domain).toMap,
manualConnect,
alias,
)
register(config)
synchronize.foreach { timeout =>
ConsoleMacros.utils.synchronize_topology(Some(timeout))(consoleEnvironment)
}
}
}
}
class LocalParticipantReferencesExtensions(participants: Seq[LocalParticipantReference])(implicit
override val consoleEnvironment: ConsoleEnvironment
) extends ParticipantReferencesExtensions(participants)
with LocalInstancesExtensions {
override def instances: Seq[LocalInstanceReferenceCommon] = participants
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlagFilter,
Helpful,
}
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
trait ConsoleCommandGroup extends Helpful with FeatureFlagFilter with NamedLogging {
protected def runner: AdminCommandRunner
protected def consoleEnvironment: ConsoleEnvironment
private[commands] def myLoggerFactory: NamedLoggerFactory = loggerFactory
}
object ConsoleCommandGroup {
class Impl(parent: ConsoleCommandGroup) extends ConsoleCommandGroup {
override protected def consoleEnvironment: ConsoleEnvironment = parent.consoleEnvironment
override protected def runner: AdminCommandRunner = parent.runner
override protected def loggerFactory: NamedLoggerFactory = parent.myLoggerFactory
}
}

View File

@ -0,0 +1,438 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.DiscardOps
import com.digitalasset.canton.admin.api.client.commands.DomainAdminCommands.GetDomainParameters
import com.digitalasset.canton.admin.api.client.commands.{
DomainAdminCommands,
TopologyAdminCommands,
}
import com.digitalasset.canton.admin.api.client.data.{
DynamicDomainParameters,
ListParticipantDomainStateResult,
StaticDomainParameters,
}
import com.digitalasset.canton.config.RequireTypes.NonNegativeInt
import com.digitalasset.canton.config.{
ConsoleCommandTimeout,
NonNegativeDuration,
NonNegativeFiniteDuration,
PositiveDurationSeconds,
}
import com.digitalasset.canton.console.CommandErrors.GenericCommandError
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlagFilter,
Help,
Helpful,
}
import com.digitalasset.canton.domain.service.ServiceAgreementAcceptance
import com.digitalasset.canton.error.CantonError
import com.digitalasset.canton.health.admin.data.NodeStatus
import com.digitalasset.canton.logging.NamedLogging
import com.digitalasset.canton.time.EnrichedDurations.*
import com.digitalasset.canton.topology.TopologyManagerError.IncreaseOfLedgerTimeRecordTimeTolerance
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.topology.admin.grpc.BaseQuery
import com.digitalasset.canton.topology.store.{TimeQuery, TopologyStoreId}
import com.digitalasset.canton.topology.transaction.*
import com.digitalasset.canton.tracing.TraceContext
import com.digitalasset.canton.util.ShowUtil.*
import com.google.protobuf.ByteString
import java.time.Duration
import scala.concurrent.{ExecutionContext, Future}
import scala.math.Ordering.Implicits.infixOrderingOps
import scala.util.chaining.scalaUtilChainingOps
trait DomainAdministration {
this: AdminCommandRunner with FeatureFlagFilter with NamedLogging =>
protected val consoleEnvironment: ConsoleEnvironment
def id: DomainId
def topology: TopologyAdministrationGroup
protected def timeouts: ConsoleCommandTimeout = consoleEnvironment.commandTimeouts
type Status <: NodeStatus.Status
def health: HealthAdministration[Status]
// The DomainTopologyTransactionMessage is about 2500 bytes and each recipient about 100 bytes.
// with this minimum we can have up to 275 recipients for a domain transaction change.
private val minimumMaxRequestSizeBytes = NonNegativeInt.tryCreate(30000)
@Help.Summary("Manage participant permissions")
@Help.Group("Participants")
object participants extends Helpful {
@Help.Summary("List participant states")
@Help.Description(
"""This command will list the currently valid state as stored in the authorized store.
| For a deep inspection of the identity management history, use the `topology.participant_domain_states.list` command."""
)
def list(): Seq[ListParticipantDomainStateResult] = {
consoleEnvironment
.run {
adminCommand(
TopologyAdminCommands.Read.ListParticipantDomainState(
BaseQuery(
filterStore = TopologyStoreId.AuthorizedStore.filterName,
useStateStore = false,
ops = None,
timeQuery = TimeQuery.HeadState,
filterSigningKey = "",
protocolVersion = None,
),
filterDomain = "",
filterParticipant = "",
)
)
}
.filter(_.item.side != RequestSide.To)
}
@Help.Summary("Change state and trust level of participant")
@Help.Description("""Set the state of the participant within the domain.
Valid permissions are 'Submission', 'Confirmation', 'Observation' and 'Disabled'.
Valid trust levels are 'Vip' and 'Ordinary'.
Synchronize timeout can be used to ensure that the state has been propagated into the node
""")
def set_state(
participant: ParticipantId,
permission: ParticipantPermission,
trustLevel: TrustLevel = TrustLevel.Ordinary,
synchronize: Option[NonNegativeDuration] = Some(timeouts.bounded),
): Unit = {
val _ = consoleEnvironment.run {
adminCommand(
TopologyAdminCommands.Write.AuthorizeParticipantDomainState(
TopologyChangeOp.Add,
None,
RequestSide.From,
id,
participant,
permission,
trustLevel,
replaceExisting = true,
)
)
}
synchronize.foreach(topology.synchronisation.await_idle)
}
@Help.Summary("Test whether a participant is permissioned on this domain")
def active(participantId: ParticipantId): Boolean =
topology.participant_domain_states.active(id, participantId)
}
@Help.Summary("Domain service commands")
@Help.Group("Service")
object service extends Helpful {
@Help.Summary("List the accepted service agreements")
def list_accepted_agreements(): Seq[ServiceAgreementAcceptance] =
consoleEnvironment.run(adminCommand(DomainAdminCommands.ListAcceptedServiceAgreements))
@Help.Summary("Get the Static Domain Parameters configured for the domain")
def get_static_domain_parameters: StaticDomainParameters =
consoleEnvironment.run(
adminCommand(GetDomainParameters())
)
@Help.Summary("Get the Dynamic Domain Parameters configured for the domain")
def get_dynamic_domain_parameters: DynamicDomainParameters = topology.domain_parameters_changes
.list("Authorized")
.sortBy(_.context.validFrom)(implicitly[Ordering[java.time.Instant]].reverse)
.headOption
.map(_.item)
.getOrElse(
throw new IllegalStateException("No dynamic domain parameters found in the domain")
)
@Help.Summary("Get the reconciliation interval configured for the domain")
@Help.Description("""Depending on the protocol version used on the domain, the value will be
read either from the static domain parameters or the dynamic ones.""")
def get_reconciliation_interval: PositiveDurationSeconds =
get_dynamic_domain_parameters.reconciliationInterval
@Help.Summary("Get the max rate per participant")
@Help.Description("""Depending on the protocol version used on the domain, the value will be
read either from the static domain parameters or the dynamic ones.""")
def get_max_rate_per_participant: NonNegativeInt =
get_dynamic_domain_parameters.maxRatePerParticipant
@Help.Summary("Get the max request size")
@Help.Description("""Depending on the protocol version used on the domain, the value will be
read either from the static domain parameters or the dynamic ones.
This value is not necessarily the one used by the sequencer node because it requires a restart
of the server to be taken into account.""")
def get_max_request_size: NonNegativeInt =
TraceContext.withNewTraceContext { implicit tc =>
get_dynamic_domain_parameters.maxRequestSize.tap { res =>
logger.info(
s"This value ($res) is not necessarily the one used by the sequencer node because it requires a restart of the server to be taken into account"
)
}
}
@Help.Summary("Get the mediator deduplication timeout")
@Help.Description(
"The method will fail, if the domain does not support the mediatorDeduplicationTimeout."
)
def get_mediator_deduplication_timeout: NonNegativeFiniteDuration =
get_dynamic_domain_parameters.mediatorDeduplicationTimeout
@Help.Summary("Update the mediator deduplication timeout")
@Help.Description(
"""The method will fail:
|
|- if the domain does not support the ``mediatorDeduplicationTimeout`` parameter,
|- if the new value of ``mediatorDeduplicationTimeout`` is less than twice the value of ``ledgerTimeRecordTimeTolerance.``"""
)
def set_mediator_deduplication_timeout(
newMediatorDeduplicationTimeout: NonNegativeFiniteDuration
): Unit =
update_dynamic_domain_parameters(
_.copy(mediatorDeduplicationTimeout = newMediatorDeduplicationTimeout)
)
@Help.Summary("Set the Dynamic Domain Parameters configured for the domain")
@Help.Description(
"""force: Enable potentially dangerous changes. Required to increase ``ledgerTimeRecordTimeTolerance``.
|Use ``set_ledger_time_record_time_tolerance`` to securely increase ``ledgerTimeRecordTimeTolerance``."""
)
def set_dynamic_domain_parameters(
dynamicDomainParameters: DynamicDomainParameters,
force: Boolean = false,
): Unit = {
val protocolVersion = get_static_domain_parameters.protocolVersion
topology.domain_parameters_changes
.authorize(id, dynamicDomainParameters, protocolVersion, force = force)
.discard[ByteString]
}
@Help.Summary("Update the Dynamic Domain Parameters for the domain")
@Help.Description(
"""force: Enable potentially dangerous changes. Required to increase ``ledgerTimeRecordTimeTolerance``.
|Use ``set_ledger_time_record_time_tolerance_securely`` to securely increase ``ledgerTimeRecordTimeTolerance``."""
)
def update_dynamic_domain_parameters(
modifier: DynamicDomainParameters => DynamicDomainParameters,
force: Boolean = false,
): Unit = {
val currentDomainParameters = get_dynamic_domain_parameters
val protocolVersion = get_static_domain_parameters.protocolVersion
val newDomainParameters = modifier(currentDomainParameters)
topology.domain_parameters_changes
.authorize(id, newDomainParameters, protocolVersion, force = force)
.discard[ByteString]
}
@Help.Summary("Try to update the reconciliation interval for the domain")
@Help.Description("""If the reconciliation interval is dynamic, update the value.
If the reconciliation interval is not dynamic (i.e., if the domain is running
on protocol version lower than `4`), then it will throw an error.
""")
def set_reconciliation_interval(
newReconciliationInterval: PositiveDurationSeconds
): Unit =
update_dynamic_domain_parameters(
_.copy(reconciliationInterval = newReconciliationInterval)
)
@Help.Summary("Try to update the max rate per participant for the domain")
@Help.Description("""If the max rate per participant is dynamic, update the value.
If the max rate per participant is not dynamic (i.e., if the domain is running
on protocol version lower than `4`), then it will throw an error.
""")
def set_max_rate_per_participant(
maxRatePerParticipant: NonNegativeInt
): Unit =
update_dynamic_domain_parameters(_.copy(maxRatePerParticipant = maxRatePerParticipant))
@Help.Summary("Try to update the max rate per participant for the domain")
@Help.Description("""If the max request size is dynamic, update the value.
The update won't have any effect unless the sequencer server is restarted.
If the max request size is not dynamic (i.e., if the domain is running
on protocol version lower than `4`), then it will throw an error.
""")
def set_max_request_size(
maxRequestSize: NonNegativeInt,
force: Boolean = false,
): Unit =
TraceContext.withNewTraceContext { implicit tc =>
if (maxRequestSize < minimumMaxRequestSizeBytes && !force)
logger.warn(
s"""|The maxRequestSize requested is lower than the minimum advised value ($minimumMaxRequestSizeBytes) which can crash Canton.
|To set this value anyway, set force to true.""".stripMargin
)
else
update_dynamic_domain_parameters(_.copy(maxRequestSize = maxRequestSize))
logger.info(
"Please restart the sequencer node to take into account the new value for max-request-size."
)
}
@Help.Summary(
"Update the `ledgerTimeRecordTimeTolerance` in the dynamic domain parameters."
)
@Help.Description(
"""If it would be insecure to perform the change immediately,
|the command will block and wait until it is secure to perform the change.
|The command will block for at most twice of ``newLedgerTimeRecordTimeTolerance``.
|
|If the domain does not support ``mediatorDeduplicationTimeout``,
|the method will update ``ledgerTimeRecordTimeTolerance`` immediately without blocking.
|
|The method will fail if ``mediatorDeduplicationTimeout`` is less than twice of ``newLedgerTimeRecordTimeTolerance``.
|
|Do not modify domain parameters concurrently while running this command,
|because the command may override concurrent changes.
|
|force: update ``ledgerTimeRecordTimeTolerance`` immediately without blocking.
|This is safe to do during domain bootstrapping and in test environments, but should not be done in operational production systems.."""
)
def set_ledger_time_record_time_tolerance(
newLedgerTimeRecordTimeTolerance: NonNegativeFiniteDuration,
force: Boolean = false,
): Unit = {
TraceContext.withNewTraceContext { implicit tc =>
get_dynamic_domain_parameters match {
case oldDomainParameters: DynamicDomainParameters if !force =>
securely_set_ledger_time_record_time_tolerance(
oldDomainParameters,
newLedgerTimeRecordTimeTolerance,
)
case _: DynamicDomainParameters =>
logger.info(
s"Immediately updating ledgerTimeRecordTimeTolerance to $newLedgerTimeRecordTimeTolerance..."
)
update_dynamic_domain_parameters(
_.update(ledgerTimeRecordTimeTolerance = newLedgerTimeRecordTimeTolerance),
force = true,
)
}
}
}
private def securely_set_ledger_time_record_time_tolerance(
oldDomainParameters: DynamicDomainParameters,
newLedgerTimeRecordTimeTolerance: NonNegativeFiniteDuration,
)(implicit traceContext: TraceContext): Unit = {
implicit val ec: ExecutionContext = consoleEnvironment.environment.executionContext
// See i9028 for a detailed design.
// https://docs.google.com/document/d/1tpPbzv2s6bjbekVGBn6X5VZuw0oOTHek5c30CBo4UkI/edit#bookmark=id.1dzc6dxxlpca
// We wait until the antecedent of Lemma 2 Item 2 is falsified for all changes that violate the conclusion.
// Compute new parameters
val oldLedgerTimeRecordTimeTolerance = oldDomainParameters.ledgerTimeRecordTimeTolerance
val minMediatorDeduplicationTimeout = newLedgerTimeRecordTimeTolerance * 2
if (oldDomainParameters.mediatorDeduplicationTimeout < minMediatorDeduplicationTimeout) {
val err = IncreaseOfLedgerTimeRecordTimeTolerance
.PermanentlyInsecure(
newLedgerTimeRecordTimeTolerance.toInternal,
oldDomainParameters.mediatorDeduplicationTimeout.toInternal,
)
val msg = CantonError.stringFromContext(err)
consoleEnvironment.run(GenericCommandError(msg))
}
logger.info(
s"Securely updating ledgerTimeRecordTimeTolerance to $newLedgerTimeRecordTimeTolerance..."
)
// Poll until it is safe to increase ledgerTimeRecordTimeTolerance
def checkPreconditions(): Future[Unit] = {
val startTs = consoleEnvironment.environment.clock.now
// Update mediatorDeduplicationTimeout for several reasons:
// 1. Make sure it is big enough.
// 2. The resulting topology transaction gives us a meaningful lower bound on the sequencer clock.
logger.info(
s"Do a no-op update of ledgerTimeRecordTimeTolerance to $oldLedgerTimeRecordTimeTolerance..."
)
update_dynamic_domain_parameters(
_.copy(ledgerTimeRecordTimeTolerance = oldLedgerTimeRecordTimeTolerance)
)
logger.debug(s"Check for incompatible past domain parameters...")
val allTransactions = topology.domain_parameters_changes.list(
id.filterString,
useStateStore = false,
// We can't specify a lower bound in range because that would be compared against validFrom.
// (But we need to compare to validUntil).
TimeQuery.Range(None, None),
)
// This serves as a lower bound of validFrom for the next topology transaction.
val lastSequencerTs =
allTransactions
.map(_.context.validFrom)
.maxOption
.getOrElse(throw new NoSuchElementException("Missing domain parameters!"))
logger.debug(s"Last sequencer timestamp is $lastSequencerTs.")
// Determine how long we need to wait until all incompatible domainParameters have become
// invalid for at least minMediatorDeduplicationTimeout.
val waitDuration = allTransactions
.filterNot(
_.item.compatibleWithNewLedgerTimeRecordTimeTolerance(newLedgerTimeRecordTimeTolerance)
)
.map { tx =>
val elapsedForAtLeast = tx.context.validUntil match {
case Some(validUntil) => Duration.between(validUntil, lastSequencerTs)
case None => Duration.ZERO
}
minMediatorDeduplicationTimeout.asJava minus elapsedForAtLeast
}
.maxOption
.getOrElse(Duration.ZERO)
if (waitDuration > Duration.ZERO) {
logger.info(
show"Found incompatible past domain parameters. Waiting for $waitDuration..."
)
// Use the clock instead of Threading.sleep to support sim clock based tests.
val delayF = consoleEnvironment.environment.clock
.scheduleAt(
_ => (),
startTs.plus(waitDuration),
) // avoid scheduleAfter, because that causes a race condition in integration tests
.onShutdown(
throw new IllegalStateException(
"Update of ledgerTimeRecordTimeTolerance interrupted due to shutdown."
)
)
// Do not submit checkPreconditions() to the clock because it is blocking and would therefore block the clock.
delayF.flatMap(_ => checkPreconditions())
} else {
Future.unit
}
}
timeouts.unbounded.await("Wait until ledgerTimeRecordTimeTolerance can be increased.")(
checkPreconditions()
)
// Now that past values of mediatorDeduplicationTimeout have been large enough,
// we can change ledgerTimeRecordTimeTolerance.
logger.info(
s"Now changing ledgerTimeRecordTimeTolerance to $newLedgerTimeRecordTimeTolerance..."
)
update_dynamic_domain_parameters(
_.copy(ledgerTimeRecordTimeTolerance = newLedgerTimeRecordTimeTolerance),
force = true,
)
}
}
}

View File

@ -0,0 +1,47 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import better.files.File
import com.digitalasset.canton.DiscardOps
import com.digitalasset.canton.util.ResourceUtil
import com.google.protobuf.ByteString
import io.grpc.stub.StreamObserver
import java.io.FileOutputStream
import scala.concurrent.Promise
import scala.language.reflectiveCalls
import scala.util.{Failure, Success, Try}
private[commands] class GrpcByteChunksToFileObserver[
T <: GrpcByteChunksToFileObserver.ByteStringChunk
](
inputFile: File,
requestComplete: Promise[String],
) extends StreamObserver[T] {
private val os: FileOutputStream = inputFile.newFileOutputStream(append = false)
override def onNext(value: T): Unit = {
Try(os.write(value.chunk.toByteArray)) match {
case Failure(exception) =>
ResourceUtil.closeAndAddSuppressed(Some(exception), os)
throw exception
case Success(_) => // all good
}
}
override def onError(t: Throwable): Unit = {
requestComplete.tryFailure(t).discard
ResourceUtil.closeAndAddSuppressed(None, os)
}
override def onCompleted(): Unit = {
requestComplete.trySuccess(inputFile.pathAsString).discard
ResourceUtil.closeAndAddSuppressed(None, os)
}
}
private[commands] object GrpcByteChunksToFileObserver {
type ByteStringChunk = { val chunk: ByteString }
}

View File

@ -0,0 +1,180 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import better.files.File
import com.digitalasset.canton.admin.api.client.commands.{
StatusAdminCommands,
TopologyAdminCommands,
TopologyAdminCommandsX,
}
import com.digitalasset.canton.config.{ConsoleCommandTimeout, NonNegativeDuration}
import com.digitalasset.canton.console.CommandErrors.{CommandError, GenericCommandError}
import com.digitalasset.canton.console.ConsoleMacros.utils
import com.digitalasset.canton.console.{
AdminCommandRunner,
CantonHealthAdministration,
CommandErrors,
CommandSuccessful,
ConsoleCommandResult,
ConsoleEnvironment,
Help,
Helpful,
}
import com.digitalasset.canton.health.admin.data.NodeStatus
import com.digitalasset.canton.health.admin.v0.HealthDumpChunk
import com.digitalasset.canton.health.admin.{data, v0}
import com.digitalasset.canton.networking.grpc.GrpcError
import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.util.ResourceUtil
import io.grpc.StatusRuntimeException
import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.{Await, Promise, TimeoutException}
abstract class HealthAdministrationCommon[S <: data.NodeStatus.Status](
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
deserialize: v0.NodeStatus.Status => ParsingResult[S],
) extends Helpful {
private val initializedCache = new AtomicReference[Boolean](false)
private def timeouts: ConsoleCommandTimeout = consoleEnvironment.commandTimeouts
import runner.*
@Help.Summary("Get human (and machine) readable status info")
def status: data.NodeStatus[S] = consoleEnvironment.run {
CommandSuccessful(adminCommand(new StatusAdminCommands.GetStatus[S](deserialize)) match {
case CommandSuccessful(success) => success
case err: CommandError => data.NodeStatus.Failure(err.cause)
})
}
@Help.Summary("Returns true if the node has an identity")
def has_identity(): Boolean
@Help.Summary("Wait for the node to have an identity")
@Help.Description(
"""This is specifically useful for the Domain Manager which needs its identity to be ready for bootstrapping,
| but for which we can't rely on wait_for_initialized() because it will be initialized only after being bootstrapped."""
)
def wait_for_identity(): Unit = waitFor(has_identity())
@Help.Summary(
"Creates a zip file containing diagnostic information about the canton process running this node"
)
def dump(
outputFile: File = CantonHealthAdministration.defaultHealthDumpName,
timeout: NonNegativeDuration = timeouts.ledgerCommand,
chunkSize: Option[Int] = None,
): String = consoleEnvironment.run {
val requestComplete = Promise[String]()
val responseObserver =
new GrpcByteChunksToFileObserver[HealthDumpChunk](outputFile, requestComplete)
def call = consoleEnvironment.run {
adminCommand(new StatusAdminCommands.GetHealthDump(responseObserver, chunkSize))
}
try {
ResourceUtil.withResource(call) { _ =>
CommandSuccessful(
Await.result(requestComplete.future, timeout.duration)
)
}
} catch {
case sre: StatusRuntimeException =>
GenericCommandError(GrpcError("Generating health dump file", "dump", sre).toString)
case _: TimeoutException =>
outputFile.delete(swallowIOExceptions = true)
CommandErrors.ConsoleTimeout.Error(timeout.asJavaApproximation)
}
}
private def runningCommand =
adminCommand(
StatusAdminCommands.IsRunning
)
private def initializedCommand =
adminCommand(
StatusAdminCommands.IsInitialized
)
def falseIfUnreachable(command: ConsoleCommandResult[Boolean]): Boolean =
consoleEnvironment.run(CommandSuccessful(command match {
case CommandSuccessful(result) => result
case _: CommandError => false
}))
@Help.Summary("Check if the node is running")
def running(): Boolean =
// in case the node is not reachable, we assume it is not running
falseIfUnreachable(runningCommand)
@Help.Summary("Check if the node is running and is the active instance (mediator, participant)")
def active: Boolean = status match {
case NodeStatus.Success(status) => status.active
case NodeStatus.NotInitialized(active) => active
case _ => false
}
@Help.Summary("Returns true if node has been initialized.")
def initialized(): Boolean = initializedCache.updateAndGet {
case false =>
// in case the node is not reachable, we cannot assume it is not initialized, because it could have been initialized in the past
// and it's simply not running at the moment. so we'll allow the command to throw an error here
consoleEnvironment.run(initializedCommand)
case x => x
}
@Help.Summary("Wait for the node to be running")
def wait_for_running(): Unit = waitFor(running())
@Help.Summary("Wait for the node to be initialized")
def wait_for_initialized(): Unit = {
waitFor(initializedCache.updateAndGet {
case false =>
// in case the node is not reachable, we return false instead of throwing an error in order to keep retrying
falseIfUnreachable(initializedCommand)
case x => x
})
}
protected def waitFor(condition: => Boolean): Unit = {
// all calls here are potentially unbounded. we do not know how long it takes
// for a node to start or for a node to become initialised. so we use the unbounded
// timeout
utils.retry_until_true(timeout = consoleEnvironment.commandTimeouts.unbounded)(condition)
}
}
class HealthAdministration[S <: data.NodeStatus.Status](
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
deserialize: v0.NodeStatus.Status => ParsingResult[S],
) extends HealthAdministrationCommon[S](runner, consoleEnvironment, deserialize) {
override def has_identity(): Boolean = runner
.adminCommand(
TopologyAdminCommands.Init.GetId()
)
.toEither
.isRight
}
class HealthAdministrationX[S <: data.NodeStatus.Status](
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
deserialize: v0.NodeStatus.Status => ParsingResult[S],
) extends HealthAdministrationCommon[S](runner, consoleEnvironment, deserialize) {
override def has_identity(): Boolean = runner
.adminCommand(
TopologyAdminCommandsX.Init.GetId()
)
.toEither
.isRight
}

View File

@ -0,0 +1,232 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.admin.api.client.commands.EnterpriseMediatorAdministrationCommands.{
Initialize,
InitializeX,
LocatePruningTimestampCommand,
Prune,
}
import com.digitalasset.canton.admin.api.client.commands.{
DomainTimeCommands,
PruningSchedulerCommands,
}
import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters
import com.digitalasset.canton.config.NonNegativeDuration
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlag,
FeatureFlagFilter,
Help,
Helpful,
}
import com.digitalasset.canton.crypto.{Fingerprint, PublicKey}
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.domain.admin.v0.EnterpriseMediatorAdministrationServiceGrpc
import com.digitalasset.canton.domain.admin.v0.EnterpriseMediatorAdministrationServiceGrpc.EnterpriseMediatorAdministrationServiceStub
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.sequencing.{SequencerConnection, SequencerConnections}
import com.digitalasset.canton.time.NonNegativeFiniteDuration
import com.digitalasset.canton.topology.store.StoredTopologyTransactions
import com.digitalasset.canton.topology.transaction.TopologyChangeOp
import com.digitalasset.canton.topology.{DomainId, MediatorId}
import scala.concurrent.duration.FiniteDuration
class MediatorTestingGroup(
runner: AdminCommandRunner,
val consoleEnvironment: ConsoleEnvironment,
val loggerFactory: NamedLoggerFactory,
) extends FeatureFlagFilter
with Helpful {
@Help.Summary("Fetch the current time from the domain", FeatureFlag.Testing)
def fetch_domain_time(
timeout: NonNegativeDuration = consoleEnvironment.commandTimeouts.ledgerCommand
): CantonTimestamp =
check(FeatureFlag.Testing) {
consoleEnvironment.run {
runner.adminCommand(
DomainTimeCommands.FetchTime(None, NonNegativeFiniteDuration.Zero, timeout)
)
}.timestamp
}
@Help.Summary("Await for the given time to be reached on the domain", FeatureFlag.Testing)
def await_domain_time(time: CantonTimestamp, timeout: NonNegativeDuration): Unit =
check(FeatureFlag.Testing) {
consoleEnvironment.run {
runner.adminCommand(
DomainTimeCommands.AwaitTime(None, time, timeout)
)
}
}
}
class MediatorPruningAdministrationGroup(
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
loggerFactory: NamedLoggerFactory,
) extends PruningSchedulerAdministration(
runner,
consoleEnvironment,
new PruningSchedulerCommands[EnterpriseMediatorAdministrationServiceStub](
EnterpriseMediatorAdministrationServiceGrpc.stub,
_.setSchedule(_),
_.clearSchedule(_),
_.setCron(_),
_.setMaxDuration(_),
_.setRetention(_),
_.getSchedule(_),
),
loggerFactory,
)
with Helpful {
@Help.Summary(
"Prune the mediator of unnecessary data while keeping data for the default retention period"
)
@Help.Description(
"""Removes unnecessary data from the Mediator that is earlier than the default retention period.
|The default retention period is set in the configuration of the canton node running this
|command under `parameters.retention-period-defaults.mediator`."""
)
def prune(): Unit = {
val defaultRetention =
consoleEnvironment.environment.config.parameters.retentionPeriodDefaults.mediator
prune_with_retention_period(defaultRetention.underlying)
}
@Help.Summary(
"Prune the mediator of unnecessary data while keeping data for the provided retention period"
)
def prune_with_retention_period(retentionPeriod: FiniteDuration): Unit = {
import scala.jdk.DurationConverters.*
val pruneUpTo = consoleEnvironment.environment.clock.now.minus(retentionPeriod.toJava)
prune_at(pruneUpTo)
}
@Help.Summary("Prune the mediator of unnecessary data up to and including the given timestamp")
def prune_at(timestamp: CantonTimestamp): Unit = consoleEnvironment.run {
runner.adminCommand(Prune(timestamp))
}
@Help.Summary("Obtain a timestamp at or near the beginning of mediator state")
@Help.Description(
"""This command provides insight into the current state of mediator pruning when called with
|the default value of `index` 1.
|When pruning the mediator manually via `prune_at` and with the intent to prune in batches, specify
|a value such as 1000 to obtain a pruning timestamp that corresponds to the "end" of the batch."""
)
def locate_pruning_timestamp(
index: PositiveInt = PositiveInt.tryCreate(1)
): Option[CantonTimestamp] =
consoleEnvironment.run {
runner.adminCommand(LocatePruningTimestampCommand(index))
}
}
class MediatorAdministrationGroup(
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
loggerFactory: NamedLoggerFactory,
) extends MediatorPruningAdministrationGroup(runner, consoleEnvironment, loggerFactory) {
private lazy val testing_ = new MediatorTestingGroup(runner, consoleEnvironment, loggerFactory)
@Help.Summary("Testing functionality for the mediator")
@Help.Group("Testing")
def testing: MediatorTestingGroup = testing_
}
@Help.Summary("Manage the mediator component")
@Help.Group("Mediator")
class MediatorAdministrationGroupWithInit(
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
loggerFactory: NamedLoggerFactory,
) extends MediatorAdministrationGroup(runner, consoleEnvironment, loggerFactory) {
@Help.Summary("Initialize a mediator")
def initialize(
domainId: DomainId,
mediatorId: MediatorId,
domainParameters: StaticDomainParameters,
sequencerConnections: SequencerConnections,
topologySnapshot: Option[StoredTopologyTransactions[TopologyChangeOp.Positive]],
signingKeyFingerprint: Option[Fingerprint] = None,
): PublicKey = consoleEnvironment.run {
runner.adminCommand(
Initialize(
domainId,
mediatorId,
topologySnapshot,
domainParameters.toInternal,
sequencerConnections,
signingKeyFingerprint,
)
)
}
@Help.Summary("Initialize a mediator")
def initialize(
domainId: DomainId,
mediatorId: MediatorId,
domainParameters: StaticDomainParameters,
sequencerConnection: SequencerConnection,
topologySnapshot: Option[StoredTopologyTransactions[TopologyChangeOp.Positive]],
signingKeyFingerprint: Option[Fingerprint],
): PublicKey = consoleEnvironment.run {
runner.adminCommand(
Initialize(
domainId,
mediatorId,
topologySnapshot,
domainParameters.toInternal,
SequencerConnections.single(sequencerConnection),
signingKeyFingerprint,
)
)
}
}
trait MediatorXAdministrationGroupWithInit extends ConsoleCommandGroup {
@Help.Summary("Methods used to initialize the node")
object setup extends ConsoleCommandGroup.Impl(this) with InitNodeId {
@Help.Summary("Assign a mediator to a domain")
def assign(
domainId: DomainId,
domainParameters: StaticDomainParameters,
sequencerConnections: SequencerConnections,
): Unit = consoleEnvironment.run {
runner.adminCommand(
InitializeX(
domainId,
domainParameters.toInternal,
sequencerConnections,
)
)
}
}
private lazy val testing_ = new MediatorTestingGroup(runner, consoleEnvironment, loggerFactory)
@Help.Summary("Testing functionality for the mediator")
@Help.Group("Testing")
def testing: MediatorTestingGroup = testing_
private lazy val pruning_ =
new MediatorPruningAdministrationGroup(runner, consoleEnvironment, loggerFactory)
@Help.Summary("Pruning functionality for the mediator")
@Help.Group("Testing")
def pruning: MediatorPruningAdministrationGroup = pruning_
}

View File

@ -0,0 +1,368 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import better.files.File
import com.digitalasset.canton.admin.api.client.commands.{
GrpcAdminCommand,
ParticipantAdminCommands,
}
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.CommandErrors.GenericCommandError
import com.digitalasset.canton.console.{
AdminCommandRunner,
CommandErrors,
CommandSuccessful,
ConsoleCommandResult,
ConsoleEnvironment,
FeatureFlag,
FeatureFlagFilter,
Help,
Helpful,
}
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.networking.grpc.GrpcError
import com.digitalasset.canton.participant.ParticipantNodeCommon
import com.digitalasset.canton.participant.admin.v0.{ExportAcsRequest, ExportAcsResponse}
import com.digitalasset.canton.participant.domain.DomainConnectionConfig
import com.digitalasset.canton.protocol.{LfContractId, SerializableContractWithWitnesses}
import com.digitalasset.canton.topology.{DomainId, PartyId}
import com.digitalasset.canton.tracing.{NoTracing, TraceContext}
import com.digitalasset.canton.util.ResourceUtil
import com.digitalasset.canton.version.ProtocolVersion
import com.digitalasset.canton.{DiscardOps, DomainAlias, SequencerCounter}
import com.google.protobuf.ByteString
import io.grpc.Context.CancellableContext
import io.grpc.StatusRuntimeException
import java.time.Instant
import java.util.UUID
import scala.concurrent.{Await, Promise, TimeoutException}
class ParticipantRepairAdministration(
val consoleEnvironment: ConsoleEnvironment,
runner: AdminCommandRunner,
val loggerFactory: NamedLoggerFactory,
) extends FeatureFlagFilter
with NoTracing
with Helpful {
@Help.Summary("Purge contracts with specified Contract IDs from local participant.")
@Help.Description(
"""This is a last resort command to recover from data corruption, e.g. in scenarios in which participant
|contracts have somehow gotten out of sync and need to be manually purged, or in situations in which
|stakeholders are no longer available to agree to their archival. The participant needs to be disconnected from
|the domain on which the contracts with "contractIds" reside at the time of the call, and as of now the domain
|cannot have had any inflight requests.
|The "ignoreAlreadyPurged" flag makes it possible to invoke the command multiple times with the same
|parameters in case an earlier command invocation has failed.
|As repair commands are powerful tools to recover from unforeseen data corruption, but dangerous under normal
|operation, use of this command requires (temporarily) enabling the "features.enable-repair-commands"
|configuration. In addition repair commands can run for an unbounded time depending on the number of
|contract ids passed in. Be sure to not connect the participant to the domain until the call returns."""
)
def purge(
domain: DomainAlias,
contractIds: Seq[LfContractId],
ignoreAlreadyPurged: Boolean = true,
): Unit =
consoleEnvironment.run {
runner.adminCommand(
ParticipantAdminCommands.ParticipantRepairManagement.PurgeContracts(
domain = domain,
contracts = contractIds,
ignoreAlreadyPurged = ignoreAlreadyPurged,
)
)
}
@Help.Summary("Migrate contracts from one domain to another one.")
@Help.Description(
"""This method can be used to migrate all the contracts associated with a domain to a new domain connection.
This method will register the new domain, connect to it and then re-associate all contracts on the source
domain to the target domain. Please note that this migration needs to be done by all participants
at the same time. The domain should only be used once all participants have finished their migration.
The arguments are:
source: the domain alias of the source domain
target: the configuration for the target domain
"""
)
def migrate_domain(
source: DomainAlias,
target: DomainConnectionConfig,
): Unit = {
consoleEnvironment.run {
runner.adminCommand(
ParticipantAdminCommands.ParticipantRepairManagement.MigrateDomain(source, target)
)
}
}
@Help.Summary("Export active contracts for the given set of parties to a file.")
@Help.Description(
"""This command exports the current Active Contract Set (ACS) of a given set of parties to ACS snapshot file.
|Afterwards, the 'import_acs' command allows importing it into a participant's ACS again.
|Such ACS export (and import) is interesting for recovery and operational purposes only.
|Note that the 'export_acs' command execution may take a long time to complete and may require significant
|resources.
"""
)
def export_acs(
parties: Set[PartyId],
outputFile: String = ParticipantRepairAdministration.ExportAcsDefaultFile,
filterDomainId: Option[DomainId] = None,
timestamp: Option[Instant] = None,
contractDomainRenames: Map[DomainId, (DomainId, ProtocolVersion)] = Map.empty,
): Unit = {
check(FeatureFlag.Repair) {
val collector = AcsSnapshotFileCollector[ExportAcsRequest, ExportAcsResponse](outputFile)
val command = ParticipantAdminCommands.ParticipantRepairManagement
.ExportAcs(
parties,
filterDomainId,
timestamp,
collector.observer,
contractDomainRenames,
)
collector.materializeFile(command)
}
}
private case class AcsSnapshotFileCollector[
Req,
Resp <: GrpcByteChunksToFileObserver.ByteStringChunk,
](outputFile: String) {
private val target = File(outputFile)
private val requestComplete = Promise[String]()
val observer = new GrpcByteChunksToFileObserver[Resp](
target,
requestComplete,
)
private val timeout = consoleEnvironment.commandTimeouts.ledgerCommand
def materializeFile(
command: GrpcAdminCommand[
Req,
CancellableContext,
CancellableContext,
]
): Unit = {
consoleEnvironment.run {
def call = consoleEnvironment.run {
runner.adminCommand(
command
)
}
try {
ResourceUtil.withResource(call) { _ =>
CommandSuccessful(
Await
.result(
requestComplete.future,
timeout.duration,
)
.discard
)
}
} catch {
case sre: StatusRuntimeException =>
GenericCommandError(
GrpcError("Generating acs snapshot file", "download_acs_snapshot", sre).toString
)
case _: TimeoutException =>
target.delete(swallowIOExceptions = true)
CommandErrors.ConsoleTimeout.Error(timeout.asJavaApproximation)
}
}
}
}
@Help.Summary("Import active contracts from an Active Contract Set (ACS) snapshot file.")
@Help.Description(
"""This command imports contracts from an ACS snapshot file into the participant's ACS.
|The given ACS snapshot file needs to be the resulting file from a previous 'export_acs' command invocation.
"""
)
def import_acs(
inputFile: String = ParticipantRepairAdministration.ExportAcsDefaultFile,
workflowIdPrefix: String = "",
): Unit = {
check(FeatureFlag.Repair) {
consoleEnvironment.run {
runner.adminCommand(
ParticipantAdminCommands.ParticipantRepairManagement.ImportAcs(
ByteString.copyFrom(File(inputFile).loadBytes),
if (workflowIdPrefix.nonEmpty) workflowIdPrefix
else s"import-${UUID.randomUUID}",
)
)
}
}
}
}
abstract class LocalParticipantRepairAdministration(
override val consoleEnvironment: ConsoleEnvironment,
runner: AdminCommandRunner,
override val loggerFactory: NamedLoggerFactory,
) extends ParticipantRepairAdministration(
consoleEnvironment = consoleEnvironment,
runner = runner,
loggerFactory = loggerFactory,
) {
protected def access[T](handler: ParticipantNodeCommon => T): T
@Help.Summary("Add specified contracts to specific domain on local participant.")
@Help.Description(
"""This is a last resort command to recover from data corruption, e.g. in scenarios in which participant
|contracts have somehow gotten out of sync and need to be manually created. The participant needs to be
|disconnected from the specified "domain" at the time of the call, and as of now the domain cannot have had
|any inflight requests.
|For each "contractsToAdd", specify "witnesses", local parties, in case no local party is a stakeholder.
|The "ignoreAlreadyAdded" flag makes it possible to invoke the command multiple times with the same
|parameters in case an earlier command invocation has failed.
|
|As repair commands are powerful tools to recover from unforeseen data corruption, but dangerous under normal
|operation, use of this command requires (temporarily) enabling the "features.enable-repair-commands"
|configuration. In addition repair commands can run for an unbounded time depending on the number of
|contracts passed in. Be sure to not connect the participant to the domain until the call returns.
|
The arguments are:
- domain: the alias of the domain to which to add the contract
- contractsToAdd: list of contracts to add with witness information
- ignoreAlreadyAdded: (default true) if set to true, it will ignore contracts that already exist on the target domain.
- ignoreStakeholderCheck: (default false) if set to true, add will work for contracts that don't have a local party (useful for party migration).
"""
)
def add(
domain: DomainAlias,
contractsToAdd: Seq[SerializableContractWithWitnesses],
ignoreAlreadyAdded: Boolean = true,
ignoreStakeholderCheck: Boolean = false,
): Unit =
runRepairCommand(tc =>
access(
_.sync.repairService
.addContracts(
domain,
contractsToAdd,
ignoreAlreadyAdded,
ignoreStakeholderCheck,
)(tc)
)
)
private def runRepairCommand[T](command: TraceContext => Either[String, T]): T =
check(FeatureFlag.Repair) {
consoleEnvironment.run {
ConsoleCommandResult.fromEither {
// Ensure that admin repair commands have a non-empty trace context.
TraceContext.withNewTraceContext(command(_))
}
}
}
@Help.Summary("Move contracts with specified Contract IDs from one domain to another.")
@Help.Description(
"""This is a last resort command to recover from data corruption in scenarios in which a domain is
|irreparably broken and formerly connected participants need to move contracts to another, healthy domain.
|The participant needs to be disconnected from both the "sourceDomain" and the "targetDomain". Also as of now
|the target domain cannot have had any inflight requests.
|Contracts already present in the target domain will be skipped, and this makes it possible to invoke this
|command in an "idempotent" fashion in case an earlier attempt had resulted in an error.
|The "skipInactive" flag makes it possible to only move active contracts in the "sourceDomain".
|As repair commands are powerful tools to recover from unforeseen data corruption, but dangerous under normal
|operation, use of this command requires (temporarily) enabling the "features.enable-repair-commands"
|configuration. In addition repair commands can run for an unbounded time depending on the number of
|contract ids passed in. Be sure to not connect the participant to either domain until the call returns.
Arguments:
- contractIds - set of contract ids that should be moved to the new domain
- sourceDomain - alias of the source domain
- targetDomain - alias of the target domain
- skipInactive - (default true) whether to skip inactive contracts mentioned in the contractIds list
- batchSize - (default 100) how many contracts to write at once to the database"""
)
def change_domain(
contractIds: Seq[LfContractId],
sourceDomain: DomainAlias,
targetDomain: DomainAlias,
skipInactive: Boolean = true,
batchSize: Int = 100,
): Unit =
runRepairCommand(tc =>
access(
_.sync.repairService.changeDomainAwait(
contractIds,
sourceDomain,
targetDomain,
skipInactive,
PositiveInt.tryCreate(batchSize),
)(tc)
)
)
@Help.Summary("Mark sequenced events as ignored.")
@Help.Description(
"""This is the last resort to ignore events that the participant is unable to process.
|Ignoring events may lead to subsequent failures, e.g., if the event creating a contract is ignored and
|that contract is subsequently used. It may also lead to ledger forks if other participants still process
|the ignored events.
|It is possible to mark events as ignored that the participant has not yet received.
|
|The command will fail, if marking events between `from` and `to` as ignored would result in a gap in sequencer counters,
|namely if `from <= to` and `from` is greater than `maxSequencerCounter + 1`,
|where `maxSequencerCounter` is the greatest sequencer counter of a sequenced event stored by the underlying participant.
|
|The command will also fail, if `force == false` and `from` is smaller than the sequencer counter of the last event
|that has been marked as clean.
|(Ignoring such events would normally have no effect, as they have already been processed.)"""
)
def ignore_events(
domainId: DomainId,
from: SequencerCounter,
to: SequencerCounter,
force: Boolean = false,
): Unit =
runRepairCommand(tc =>
access {
_.sync.repairService.ignoreEvents(domainId, from, to, force)(tc)
}
)
@Help.Summary("Remove the ignored status from sequenced events.")
@Help.Description(
"""This command has no effect on ordinary (i.e., not ignored) events and on events that do not exist.
|
|The command will fail, if marking events between `from` and `to` as unignored would result in a gap in sequencer counters,
|namely if there is one empty ignored event with sequencer counter between `from` and `to` and
|another empty ignored event with sequencer counter greater than `to`.
|An empty ignored event is an event that has been marked as ignored and not yet received by the participant.
|
|The command will also fail, if `force == false` and `from` is smaller than the sequencer counter of the last event
|that has been marked as clean.
|(Unignoring such events would normally have no effect, as they have already been processed.)"""
)
def unignore_events(
domainId: DomainId,
from: SequencerCounter,
to: SequencerCounter,
force: Boolean = false,
): Unit =
runRepairCommand(tc =>
access {
_.sync.repairService.unignoreEvents(domainId, from, to, force)(tc)
}
)
}
object ParticipantRepairAdministration {
private val DefaultFile = "canton-acs-snapshot.gz"
private val ExportAcsDefaultFile = "canton-acs-export.gz"
}

View File

@ -0,0 +1,398 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import cats.syntax.either.*
import cats.syntax.foldable.*
import cats.syntax.traverse.*
import com.digitalasset.canton.LedgerParticipantId
import com.digitalasset.canton.admin.api.client.commands.{
ParticipantAdminCommands,
TopologyAdminCommands,
}
import com.digitalasset.canton.admin.api.client.data.{
ListConnectedDomainsResult,
ListPartiesResult,
PartyDetails,
}
import com.digitalasset.canton.config.CantonRequireTypes.String255
import com.digitalasset.canton.config.NonNegativeDuration
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.{
AdminCommandRunner,
BaseInspection,
CantonInternalError,
CommandFailure,
ConsoleCommandResult,
ConsoleEnvironment,
ConsoleMacros,
FeatureFlag,
FeatureFlagFilter,
Help,
Helpful,
LocalParticipantReference,
ParticipantReference,
}
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.participant.ParticipantNode
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.topology.transaction.{
ParticipantPermission,
RequestSide,
TopologyChangeOp,
}
import com.digitalasset.canton.tracing.TraceContext
import com.digitalasset.canton.util.ShowUtil.*
import com.google.protobuf.ByteString
import java.time.Instant
import scala.util.Try
class PartiesAdministrationGroup(runner: AdminCommandRunner, consoleEnvironment: ConsoleEnvironment)
extends Helpful {
protected def defaultLimit: PositiveInt =
consoleEnvironment.environment.config.parameters.console.defaultLimit
import runner.*
@Help.Summary(
"List active parties, their active participants, and the participants' permissions on domains."
)
@Help.Description(
"""Inspect the parties known by this participant as used for synchronisation.
|The response is built from the timestamped topology transactions of each domain, excluding the
|authorized store of the given node. For each known party, the list of active
|participants and their permission on the domain for that party is given.
|
filterParty: Filter by parties starting with the given string.
filterParticipant: Filter for parties that are hosted by a participant with an id starting with the given string
filterDomain: Filter by domains whose id starts with the given string.
asOf: Optional timestamp to inspect the topology state at a given point in time.
limit: Limit on the number of parties fetched (defaults to canton.parameters.console.default-limit).
Example: participant1.parties.list(filterParty="alice")
"""
)
def list(
filterParty: String = "",
filterParticipant: String = "",
filterDomain: String = "",
asOf: Option[Instant] = None,
limit: PositiveInt = defaultLimit,
): Seq[ListPartiesResult] =
consoleEnvironment.run {
adminCommand(
TopologyAdminCommands.Aggregation.ListParties(
filterDomain = filterDomain,
filterParty = filterParty,
filterParticipant = filterParticipant,
asOf = asOf,
limit = limit,
)
)
}
}
class ParticipantPartiesAdministrationGroup(
participantId: => ParticipantId,
runner: AdminCommandRunner & ParticipantAdministration & BaseLedgerApiAdministration,
consoleEnvironment: ConsoleEnvironment,
) extends PartiesAdministrationGroup(runner, consoleEnvironment) {
@Help.Summary("List parties hosted by this participant")
@Help.Description("""Inspect the parties hosted by this participant as used for synchronisation.
|The response is built from the timestamped topology transactions of each domain, excluding the
|authorized store of the given node. The search will include all hosted parties and is equivalent
|to running the `list` method using the participant id of the invoking participant.
|
filterParty: Filter by parties starting with the given string.
filterDomain: Filter by domains whose id starts with the given string.
asOf: Optional timestamp to inspect the topology state at a given point in time.
limit: How many items to return (defaults to canton.parameters.console.default-limit)
Example: participant1.parties.hosted(filterParty="alice")""")
def hosted(
filterParty: String = "",
filterDomain: String = "",
asOf: Option[Instant] = None,
limit: PositiveInt = defaultLimit,
): Seq[ListPartiesResult] = {
list(
filterParty,
filterParticipant = participantId.filterString,
filterDomain = filterDomain,
asOf = asOf,
limit = limit,
)
}
@Help.Summary("Find a party from a filter string")
@Help.Description(
"""Will search for all parties that match this filter string. If it finds exactly one party, it
|will return that one. Otherwise, the function will throw."""
)
def find(filterParty: String): PartyId = {
list(filterParty).map(_.party).distinct.toList match {
case one :: Nil => one
case Nil => throw new IllegalArgumentException(s"No party matching $filterParty")
case more =>
throw new IllegalArgumentException(s"Multiple parties match $filterParty: $more")
}
}
@Help.Summary("Enable/add party to participant")
@Help.Description("""This function registers a new party with the current participant within the participants
|namespace. The function fails if the participant does not have appropriate signing keys
|to issue the corresponding PartyToParticipant topology transaction.
|Optionally, a local display name can be added. This display name will be exposed on the
|ledger API party management endpoint.
|Specifying a set of domains via the `WaitForDomain` parameter ensures that the domains have
|enabled/added a party by the time the call returns, but other participants connected to the same domains may not
|yet be aware of the party.
|Additionally, a sequence of additional participants can be added to be synchronized to
|ensure that the party is known to these participants as well before the function terminates.
|""")
def enable(
name: String,
displayName: Option[String] = None,
// TODO(i10809) replace wait for domain for a clean topology synchronisation using the dispatcher info
waitForDomain: DomainChoice = DomainChoice.Only(Seq()),
synchronizeParticipants: Seq[ParticipantReference] = Seq(),
): PartyId = {
def registered(lst: => Seq[ListPartiesResult]): Set[DomainId] = {
lst
.flatMap(_.participants.flatMap(_.domains))
.map(_.domain)
.toSet
}
def primaryRegistered(partyId: PartyId) =
registered(
list(filterParty = partyId.filterString, filterParticipant = participantId.filterString)
)
def primaryConnected: Either[String, Seq[ListConnectedDomainsResult]] =
runner
.adminCommand(ParticipantAdminCommands.DomainConnectivity.ListConnectedDomains())
.toEither
def findDomainIds(
name: String,
connected: Either[String, Seq[ListConnectedDomainsResult]],
): Either[String, Set[DomainId]] = {
for {
domainIds <- waitForDomain match {
case DomainChoice.All =>
connected.map(_.map(_.domainId))
case DomainChoice.Only(Seq()) =>
Right(Seq())
case DomainChoice.Only(aliases) =>
connected.flatMap { res =>
val connectedM = res.map(x => (x.domainAlias, x.domainId)).toMap
aliases.traverse(alias => connectedM.get(alias).toRight(s"Unknown: $alias for $name"))
}
}
} yield domainIds.toSet
}
def retryE(condition: => Boolean, message: => String): Either[String, Unit] = {
AdminCommandRunner
.retryUntilTrue(consoleEnvironment.commandTimeouts.ledgerCommand)(condition)
.toEither
.leftMap(_ => message)
}
def waitForParty(
partyId: PartyId,
domainIds: Set[DomainId],
registered: => Set[DomainId],
queriedParticipant: ParticipantId = participantId,
): Either[String, Unit] = {
if (domainIds.nonEmpty) {
retryE(
domainIds subsetOf registered,
show"Party $partyId did not appear for $queriedParticipant on domain ${domainIds.diff(registered)}",
)
} else Right(())
}
val syncLedgerApi = waitForDomain match {
case DomainChoice.All => true
case DomainChoice.Only(aliases) => aliases.nonEmpty
}
consoleEnvironment.run {
ConsoleCommandResult.fromEither {
for {
// validating party and display name here to prevent, e.g., a party being registered despite it having an invalid display name
// assert that name is valid ParticipantId
id <- Identifier.create(name)
partyId = PartyId(participantId.uid.copy(id = id))
_ <- Either
.catchOnly[IllegalArgumentException](LedgerParticipantId.assertFromString(name))
.leftMap(_.getMessage)
validDisplayName <- displayName.map(String255.create(_, Some("display name"))).sequence
// find the domain ids
domainIds <- findDomainIds(this.participantId.uid.id.unwrap, primaryConnected)
// find the domain ids the additional participants are connected to
additionalSync <- synchronizeParticipants.traverse { p =>
findDomainIds(
p.name,
Try(p.domains.list_connected()).toEither.leftMap {
case exception @ (_: CommandFailure | _: CantonInternalError) =>
exception.getMessage
case exception => throw exception
},
)
.map(domains => (p, domains intersect domainIds))
}
_ <- runPartyCommand(partyId, TopologyChangeOp.Add).toEither
_ <- validDisplayName match {
case None => Right(())
case Some(name) =>
runner
.adminCommand(
ParticipantAdminCommands.PartyNameManagement
.SetPartyDisplayName(partyId, name.unwrap)
)
.toEither
}
_ <- waitForParty(partyId, domainIds, primaryRegistered(partyId))
_ <-
// sync with ledger-api server if this node is connected to at least one domain
if (syncLedgerApi && primaryConnected.exists(_.nonEmpty))
retryE(
runner.ledger_api.parties.list().map(_.party).contains(partyId),
show"The party $partyId never appeared on the ledger API server",
)
else Right(())
_ <- additionalSync.traverse_ { case (p, domains) =>
waitForParty(
partyId,
domains,
registered(
p.parties.list(
filterParty = partyId.filterString,
filterParticipant = participantId.filterString,
)
),
p.id,
)
}
} yield partyId
}
}
}
private def runPartyCommand(
partyId: PartyId,
op: TopologyChangeOp,
force: Boolean = false,
): ConsoleCommandResult[ByteString] = {
runner
.adminCommand(
TopologyAdminCommands.Write.AuthorizePartyToParticipant(
op,
None,
RequestSide.Both,
partyId,
participantId,
ParticipantPermission.Submission,
replaceExisting = false,
force = force,
)
)
}
@Help.Summary("Disable party on participant")
def disable(name: Identifier, force: Boolean = false): Unit = {
val partyId = PartyId(participantId.uid.copy(id = name))
val _ = consoleEnvironment.run {
runPartyCommand(partyId, TopologyChangeOp.Remove, force)
}
}
@Help.Summary("Update participant-local party details")
@Help.Description(
"""Currently you can update only the annotations.
|You cannot update other user attributes.
party: party to be updated,
modifier: a function to modify the party details, e.g.: `partyDetails => { partyDetails.copy(annotations = partyDetails.annotations.updated("a", "b").removed("c")) }`"""
)
def update(
party: PartyId,
modifier: PartyDetails => PartyDetails,
): PartyDetails = {
runner.ledger_api.parties.update(
party = party,
modifier = modifier,
)
}
@Help.Summary("Set party display name")
@Help.Description(
"Locally set the party display name (shown on the ledger-api) to the given value"
)
def set_display_name(party: PartyId, displayName: String): Unit = consoleEnvironment.run {
// takes displayName as String argument which is validated at GrpcPartyNameManagementService
runner.adminCommand(
ParticipantAdminCommands.PartyNameManagement.SetPartyDisplayName(party, displayName)
)
}
}
class LocalParticipantPartiesAdministrationGroup(
reference: LocalParticipantReference,
runner: AdminCommandRunner
& BaseInspection[ParticipantNode]
& ParticipantAdministration
& BaseLedgerApiAdministration,
val consoleEnvironment: ConsoleEnvironment,
val loggerFactory: NamedLoggerFactory,
) extends ParticipantPartiesAdministrationGroup(reference.id, runner, consoleEnvironment)
with FeatureFlagFilter {
import runner.*
@Help.Summary("Waits for any topology changes to be observed", FeatureFlag.Preview)
@Help.Description(
"Will throw an exception if the given topology has not been observed within the given timeout."
)
def await_topology_observed[T <: ParticipantReference](
partyAssignment: Set[(PartyId, T)],
timeout: NonNegativeDuration = consoleEnvironment.commandTimeouts.bounded,
)(implicit env: ConsoleEnvironment): Unit =
check(FeatureFlag.Preview) {
access(node =>
TopologySynchronisation.awaitTopologyObserved(reference, partyAssignment, timeout)
)
}
}
object TopologySynchronisation {
def awaitTopologyObserved[T <: ParticipantReference](
reference: ParticipantReference,
partyAssignment: Set[(PartyId, T)],
timeout: NonNegativeDuration,
)(implicit env: ConsoleEnvironment): Unit =
TraceContext.withNewTraceContext { _ =>
ConsoleMacros.utils.retry_until_true(timeout) {
val partiesWithId = partyAssignment.map { case (party, participantRef) =>
(party, participantRef.id)
}
env.domains.all.forall { domain =>
val domainId = domain.id
!reference.domains.active(domain) || {
val timestamp = reference.testing.fetch_domain_time(domainId)
partiesWithId.subsetOf(
reference.parties
.list(asOf = Some(timestamp.toInstant))
.flatMap(res => res.participants.map(par => (res.party, par.participant)))
.toSet
)
}
}
}
}
}

View File

@ -0,0 +1,430 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import cats.syntax.either.*
import cats.syntax.foldable.*
import cats.syntax.traverse.*
import com.digitalasset.canton.LedgerParticipantId
import com.digitalasset.canton.admin.api.client.commands.{
ParticipantAdminCommands,
TopologyAdminCommands,
TopologyAdminCommandsX,
}
import com.digitalasset.canton.admin.api.client.data.{
ListConnectedDomainsResult,
ListPartiesResult,
PartyDetails,
}
import com.digitalasset.canton.config.CantonRequireTypes.String255
import com.digitalasset.canton.config.NonNegativeDuration
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.{
AdminCommandRunner,
BaseInspection,
CantonInternalError,
CommandFailure,
ConsoleCommandResult,
ConsoleEnvironment,
ConsoleMacros,
FeatureFlag,
FeatureFlagFilter,
Help,
Helpful,
InstanceReferenceX,
LocalParticipantReferenceX,
ParticipantReferenceX,
}
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.participant.ParticipantNodeX
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.topology.store.TopologyStoreId.AuthorizedStore
import com.digitalasset.canton.topology.transaction.*
import com.digitalasset.canton.tracing.TraceContext
import com.digitalasset.canton.util.ShowUtil.*
import java.time.Instant
import scala.util.Try
class PartiesAdministrationGroupX(
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
) extends Helpful {
protected def defaultLimit: PositiveInt =
consoleEnvironment.environment.config.parameters.console.defaultLimit
import runner.*
@Help.Summary(
"List active parties, their active participants, and the participants' permissions on domains."
)
@Help.Description(
"""Inspect the parties known by this participant as used for synchronisation.
|The response is built from the timestamped topology transactions of each domain, excluding the
|authorized store of the given node. For each known party, the list of active
|participants and their permission on the domain for that party is given.
|
filterParty: Filter by parties starting with the given string.
filterParticipant: Filter for parties that are hosted by a participant with an id starting with the given string
filterDomain: Filter by domains whose id starts with the given string.
asOf: Optional timestamp to inspect the topology state at a given point in time.
limit: Limit on the number of parties fetched (defaults to canton.parameters.console.default-limit).
Example: participant1.parties.list(filterParty="alice")
"""
)
def list(
filterParty: String = "",
filterParticipant: String = "",
filterDomain: String = "",
asOf: Option[Instant] = None,
limit: PositiveInt = defaultLimit,
): Seq[ListPartiesResult] =
consoleEnvironment.run {
adminCommand(
TopologyAdminCommands.Aggregation.ListParties(
filterDomain = filterDomain,
filterParty = filterParty,
filterParticipant = filterParticipant,
asOf = asOf,
limit = limit,
)
)
}
}
class ParticipantPartiesAdministrationGroupX(
participantId: => ParticipantId,
runner: AdminCommandRunner
& ParticipantAdministration
& BaseLedgerApiAdministration
& InstanceReferenceX,
consoleEnvironment: ConsoleEnvironment,
) extends PartiesAdministrationGroupX(runner, consoleEnvironment) {
@Help.Summary("List parties hosted by this participant")
@Help.Description("""Inspect the parties hosted by this participant as used for synchronisation.
|The response is built from the timestamped topology transactions of each domain, excluding the
|authorized store of the given node. The search will include all hosted parties and is equivalent
|to running the `list` method using the participant id of the invoking participant.
|
filterParty: Filter by parties starting with the given string.
filterDomain: Filter by domains whose id starts with the given string.
asOf: Optional timestamp to inspect the topology state at a given point in time.
limit: How many items to return (defaults to canton.parameters.console.default-limit)
Example: participant1.parties.hosted(filterParty="alice")""")
def hosted(
filterParty: String = "",
filterDomain: String = "",
asOf: Option[Instant] = None,
limit: PositiveInt = defaultLimit,
): Seq[ListPartiesResult] = {
list(
filterParty,
filterParticipant = participantId.filterString,
filterDomain = filterDomain,
asOf = asOf,
limit = limit,
)
}
@Help.Summary("Find a party from a filter string")
@Help.Description(
"""Will search for all parties that match this filter string. If it finds exactly one party, it
|will return that one. Otherwise, the function will throw."""
)
def find(filterParty: String): PartyId = {
list(filterParty).map(_.party).distinct.toList match {
case one :: Nil => one
case Nil => throw new IllegalArgumentException(s"No party matching $filterParty")
case more =>
throw new IllegalArgumentException(s"Multiple parties match $filterParty: $more")
}
}
@Help.Summary("Enable/add party to participant")
@Help.Description("""This function registers a new party with the current participant within the participants
|namespace. The function fails if the participant does not have appropriate signing keys
|to issue the corresponding PartyToParticipant topology transaction.
|Optionally, a local display name can be added. This display name will be exposed on the
|ledger API party management endpoint.
|Specifying a set of domains via the `WaitForDomain` parameter ensures that the domains have
|enabled/added a party by the time the call returns, but other participants connected to the same domains may not
|yet be aware of the party.
|Additionally, a sequence of additional participants can be added to be synchronized to
|ensure that the party is known to these participants as well before the function terminates.
|""")
def enable(
name: String,
namespace: Namespace = participantId.uid.namespace,
participants: Seq[ParticipantId] = Seq(participantId),
threshold: PositiveInt = PositiveInt.one,
displayName: Option[String] = None,
// TODO(i10809) replace wait for domain for a clean topology synchronisation using the dispatcher info
waitForDomain: DomainChoice = DomainChoice.Only(Seq()),
synchronizeParticipants: Seq[ParticipantReferenceX] = Seq(),
groupAddressing: Boolean = false,
mustFullyAuthorize: Boolean = true,
): PartyId = {
def registered(lst: => Seq[ListPartiesResult]): Set[DomainId] = {
lst
.flatMap(_.participants.flatMap(_.domains))
.map(_.domain)
.toSet
}
def primaryRegistered(partyId: PartyId) =
registered(
list(filterParty = partyId.filterString, filterParticipant = participantId.filterString)
)
def primaryConnected: Either[String, Seq[ListConnectedDomainsResult]] =
runner
.adminCommand(ParticipantAdminCommands.DomainConnectivity.ListConnectedDomains())
.toEither
def findDomainIds(
name: String,
connected: Either[String, Seq[ListConnectedDomainsResult]],
): Either[String, Set[DomainId]] = {
for {
domainIds <- waitForDomain match {
case DomainChoice.All =>
connected.map(_.map(_.domainId))
case DomainChoice.Only(Seq()) =>
Right(Seq())
case DomainChoice.Only(aliases) =>
connected.flatMap { res =>
val connectedM = res.map(x => (x.domainAlias, x.domainId)).toMap
aliases.traverse(alias => connectedM.get(alias).toRight(s"Unknown: $alias for $name"))
}
}
} yield domainIds.toSet
}
def retryE(condition: => Boolean, message: => String): Either[String, Unit] = {
AdminCommandRunner
.retryUntilTrue(consoleEnvironment.commandTimeouts.ledgerCommand)(condition)
.toEither
.leftMap(_ => message)
}
def waitForParty(
partyId: PartyId,
domainIds: Set[DomainId],
registered: => Set[DomainId],
queriedParticipant: ParticipantId = participantId,
): Either[String, Unit] = {
if (domainIds.nonEmpty) {
retryE(
domainIds subsetOf registered,
show"Party $partyId did not appear for $queriedParticipant on domain ${domainIds.diff(registered)}",
)
} else Right(())
}
val syncLedgerApi = waitForDomain match {
case DomainChoice.All => true
case DomainChoice.Only(aliases) => aliases.nonEmpty
}
consoleEnvironment.run {
ConsoleCommandResult.fromEither {
for {
// validating party and display name here to prevent, e.g., a party being registered despite it having an invalid display name
// assert that name is valid ParticipantId
id <- Identifier.create(name)
partyId = PartyId(id, namespace)
_ <- Either
.catchOnly[IllegalArgumentException](LedgerParticipantId.assertFromString(name))
.leftMap(_.getMessage)
validDisplayName <- displayName.map(String255.create(_, Some("display name"))).sequence
// find the domain ids
domainIds <- findDomainIds(this.participantId.uid.id.unwrap, primaryConnected)
// find the domain ids the additional participants are connected to
additionalSync <- synchronizeParticipants.traverse { p =>
findDomainIds(
p.name,
Try(p.domains.list_connected()).toEither.leftMap {
case exception @ (_: CommandFailure | _: CantonInternalError) =>
exception.getMessage
case exception => throw exception
},
)
.map(domains => (p, domains intersect domainIds))
}
_ <- runPartyCommand(
partyId,
participants,
threshold,
groupAddressing,
mustFullyAuthorize,
).toEither
_ <- validDisplayName match {
case None => Right(())
case Some(name) =>
runner
.adminCommand(
ParticipantAdminCommands.PartyNameManagement
.SetPartyDisplayName(partyId, name.unwrap)
)
.toEither
}
_ <- waitForParty(partyId, domainIds, primaryRegistered(partyId))
_ <-
// sync with ledger-api server if this node is connected to at least one domain
if (syncLedgerApi && primaryConnected.exists(_.nonEmpty))
retryE(
runner.ledger_api.parties.list().map(_.party).contains(partyId),
show"The party $partyId never appeared on the ledger API server",
)
else Right(())
_ <- additionalSync.traverse_ { case (p, domains) =>
waitForParty(
partyId,
domains,
registered(
p.parties.list(
filterParty = partyId.filterString,
filterParticipant = participantId.filterString,
)
),
p.id,
)
}
} yield partyId
}
}
}
private def runPartyCommand(
partyId: PartyId,
participants: Seq[ParticipantId],
threshold: PositiveInt,
groupAddressing: Boolean,
mustFullyAuthorize: Boolean,
): ConsoleCommandResult[SignedTopologyTransactionX[TopologyChangeOpX, PartyToParticipantX]] = {
runner
.adminCommand(
TopologyAdminCommandsX.Write.Propose(
// TODO(#14048) properly set the serial or introduce auto-detection so we don't
// have to set it on the client side
mapping = PartyToParticipantX(
partyId,
None,
threshold,
participants.map(pid =>
HostingParticipant(
pid,
if (threshold.value > 1) ParticipantPermissionX.Confirmation
else ParticipantPermissionX.Submission,
)
),
groupAddressing,
),
signedBy = Seq(this.participantId.uid.namespace.fingerprint),
serial = None,
store = AuthorizedStore.filterName,
mustFullyAuthorize = mustFullyAuthorize,
)
)
}
@Help.Summary("Disable party on participant")
// TODO(#14067): reintroduce `force` once it is implemented on the server side and threaded through properly.
def disable(name: Identifier /*, force: Boolean = false*/ ): Unit = {
runner.topology.party_to_participant_mappings
.propose_delta(
PartyId(name, runner.id.member.uid.namespace),
removes = List(this.participantId),
)
.discard
}
@Help.Summary("Update participant-local party details")
@Help.Description(
"""Currently you can update only the annotations.
|You cannot update other user attributes.
party: party to be updated,
modifier: a function to modify the party details, e.g.: `partyDetails => { partyDetails.copy(annotations = partyDetails.annotations.updated("a", "b").removed("c")) }`"""
)
def update(
party: PartyId,
modifier: PartyDetails => PartyDetails,
): PartyDetails = {
runner.ledger_api.parties.update(
party = party,
modifier = modifier,
)
}
@Help.Summary("Set party display name")
@Help.Description(
"Locally set the party display name (shown on the ledger-api) to the given value"
)
def set_display_name(party: PartyId, displayName: String): Unit = consoleEnvironment.run {
// takes displayName as String argument which is validated at GrpcPartyNameManagementService
runner.adminCommand(
ParticipantAdminCommands.PartyNameManagement.SetPartyDisplayName(party, displayName)
)
}
}
class LocalParticipantPartiesAdministrationGroupX(
reference: LocalParticipantReferenceX,
runner: AdminCommandRunner
& BaseInspection[ParticipantNodeX]
& ParticipantAdministration
& BaseLedgerApiAdministration
& InstanceReferenceX,
val consoleEnvironment: ConsoleEnvironment,
val loggerFactory: NamedLoggerFactory,
) extends ParticipantPartiesAdministrationGroupX(reference.id, runner, consoleEnvironment)
with FeatureFlagFilter {
import runner.*
@Help.Summary("Waits for any topology changes to be observed", FeatureFlag.Preview)
@Help.Description(
"Will throw an exception if the given topology has not been observed within the given timeout."
)
def await_topology_observed[T <: ParticipantReferenceX](
partyAssignment: Set[(PartyId, T)],
timeout: NonNegativeDuration = consoleEnvironment.commandTimeouts.bounded,
)(implicit env: ConsoleEnvironment): Unit =
check(FeatureFlag.Preview) {
access(node =>
TopologySynchronisationX.awaitTopologyObserved(reference, partyAssignment, timeout)
)
}
}
object TopologySynchronisationX {
def awaitTopologyObserved[T <: ParticipantReferenceX](
reference: ParticipantReferenceX,
partyAssignment: Set[(PartyId, T)],
timeout: NonNegativeDuration,
)(implicit env: ConsoleEnvironment): Unit =
TraceContext.withNewTraceContext { _ =>
ConsoleMacros.utils.retry_until_true(timeout) {
val partiesWithId = partyAssignment.map { case (party, participantRef) =>
(party, participantRef.id)
}
env.domains.all.forall { domain =>
val domainId = domain.id
!reference.domains.active(domain) || {
val timestamp = reference.testing.fetch_domain_time(domainId)
partiesWithId.subsetOf(
reference.parties
.list(asOf = Some(timestamp.toInstant))
.flatMap(res => res.participants.map(par => (res.party, par.participant)))
.toSet
)
}
}
}
}
}

View File

@ -0,0 +1,109 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.admin.api.client.commands.PruningSchedulerCommands
import com.digitalasset.canton.admin.api.client.data.PruningSchedule
import com.digitalasset.canton.config.PositiveDurationSeconds
import com.digitalasset.canton.console.{AdminCommandRunner, ConsoleEnvironment, Help, Helpful}
import com.digitalasset.canton.logging.NamedLoggerFactory
import io.grpc.stub.AbstractStub
/** Pruning scheduler administration api shared by participant/mediator/sequencer.
*/
class PruningSchedulerAdministration[T <: AbstractStub[T]](
runner: AdminCommandRunner,
protected val consoleEnvironment: ConsoleEnvironment,
commands: PruningSchedulerCommands[T],
protected val loggerFactory: NamedLoggerFactory,
) extends Helpful {
@Help.Summary(
"Activate automatic pruning according to the specified schedule."
)
@Help.Description(
"""The schedule is specified in cron format and "max_duration" and "retention" durations. The cron string indicates
|the points in time at which pruning should begin in the GMT time zone, and the maximum duration indicates how
|long from the start time pruning is allowed to run as long as pruning has not finished pruning up to the
|specified retention period.
"""
)
def set_schedule(
cron: String,
maxDuration: PositiveDurationSeconds,
retention: PositiveDurationSeconds,
): Unit =
consoleEnvironment.run(
runner.adminCommand(
commands.SetScheduleCommand(cron = cron, maxDuration = maxDuration, retention = retention)
)
)
@Help.Summary("Deactivate automatic pruning.")
def clear_schedule(): Unit =
consoleEnvironment.run(
runner.adminCommand(commands.ClearScheduleCommand())
)
@Help.Summary("Modify the cron used by automatic pruning.")
@Help.Description(
"""The schedule is specified in cron format and refers to pruning start times in the GMT time zone.
|This call returns an error if no schedule has been configured via `set_schedule` or if automatic
|pruning has been disabled via `clear_schedule`. Additionally if at the time of this modification, pruning is
|actively running, a best effort is made to pause pruning and restart according to the new schedule. This
|allows for the case that the new schedule no longer allows pruning at the current time.
"""
)
def set_cron(cron: String): Unit =
consoleEnvironment.run(
runner.adminCommand(commands.SetCronCommand(cron))
)
@Help.Summary("Modify the maximum duration used by automatic pruning.")
@Help.Description(
"""The `maxDuration` is specified as a positive duration and has at most per-second granularity.
|This call returns an error if no schedule has been configured via `set_schedule` or if automatic
|pruning has been disabled via `clear_schedule`. Additionally if at the time of this modification, pruning is
|actively running, a best effort is made to pause pruning and restart according to the new schedule. This
|allows for the case that the new schedule no longer allows pruning at the current time.
"""
)
def set_max_duration(maxDuration: PositiveDurationSeconds): Unit =
consoleEnvironment.run(
runner.adminCommand(
commands.SetMaxDurationCommand(maxDuration)
)
)
@Help.Summary("Update the pruning retention used by automatic pruning.")
@Help.Description(
"""The `retention` is specified as a positive duration and has at most per-second granularity.
|This call returns an error if no schedule has been configured via `set_schedule` or if automatic
|pruning has been disabled via `clear_schedule`. Additionally if at the time of this update, pruning is
|actively running, a best effort is made to pause pruning and restart with the newly specified retention.
|This allows for the case that the new retention mandates retaining more data than previously.
"""
)
def set_retention(retention: PositiveDurationSeconds): Unit =
consoleEnvironment.run(
runner.adminCommand(
commands.SetRetentionCommand(retention)
)
)
@Help.Summary("Inspect the automatic pruning schedule.")
@Help.Description(
"""The schedule consists of a "cron" expression and "max_duration" and "retention" durations. The cron string
|indicates the points in time at which pruning should begin in the GMT time zone, and the maximum duration
|indicates how long from the start time pruning is allowed to run as long as pruning has not finished pruning
|up to the specified retention period.
|Returns `None` if no schedule has been configured via `set_schedule` or if `clear_schedule` has been invoked.
"""
)
def get_schedule(): Option[PruningSchedule] =
consoleEnvironment.run(
runner.adminCommand(commands.GetScheduleCommand())
)
}

View File

@ -0,0 +1,274 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.admin.api.client.commands.EnterpriseSequencerAdminCommands.LocatePruningTimestampCommand
import com.digitalasset.canton.admin.api.client.commands.{
EnterpriseSequencerAdminCommands,
PruningSchedulerCommands,
SequencerAdminCommands,
}
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlag,
Help,
Helpful,
}
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.domain.admin.v0.EnterpriseSequencerAdministrationServiceGrpc
import com.digitalasset.canton.domain.admin.v0.EnterpriseSequencerAdministrationServiceGrpc.EnterpriseSequencerAdministrationServiceStub
import com.digitalasset.canton.domain.sequencing.sequencer.{
SequencerClients,
SequencerPruningStatus,
SequencerSnapshot,
}
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.time.EnrichedDurations.*
import com.digitalasset.canton.topology.Member
import com.digitalasset.canton.util.ShowUtil.*
import scala.concurrent.duration.FiniteDuration
import scala.jdk.DurationConverters.*
trait SequencerAdministrationGroupCommon extends ConsoleCommandGroup {
@Help.Summary("Pruning of the sequencer")
object pruning
extends PruningSchedulerAdministration(
runner,
consoleEnvironment,
new PruningSchedulerCommands[EnterpriseSequencerAdministrationServiceStub](
EnterpriseSequencerAdministrationServiceGrpc.stub,
_.setSchedule(_),
_.clearSchedule(_),
_.setCron(_),
_.setMaxDuration(_),
_.setRetention(_),
_.getSchedule(_),
),
loggerFactory,
)
with Helpful {
@Help.Summary("Status of the sequencer and its connected clients")
@Help.Description(
"""Provides a detailed breakdown of information required for pruning:
| - the current time according to this sequencer instance
| - domain members that the sequencer supports
| - for each member when they were registered and whether they are enabled
| - a list of clients for each member, their last acknowledgement, and whether they are enabled
|"""
)
def status(): SequencerPruningStatus =
consoleEnvironment.run {
runner.adminCommand(SequencerAdminCommands.GetPruningStatus)
}
@Help.Summary("Remove unnecessary data from the Sequencer up until the default retention point")
@Help.Description(
"""Removes unnecessary data from the Sequencer that is earlier than the default retention period.
|The default retention period is set in the configuration of the canton processing running this
|command under `parameters.retention-period-defaults.sequencer`.
|This pruning command requires that data is read and acknowledged by clients before
|considering it safe to remove.
|
|If no data is being removed it could indicate that clients are not reading or acknowledging data
|in a timely fashion (typically due to nodes going offline for long periods).
|You have the option of disabling the members running on these nodes to allow removal of this data,
|however this will mean that they will be unable to reconnect to the domain in the future.
|To do this run `force_prune(dryRun = true)` to return a description of which members would be
|disabled in order to prune the Sequencer.
|If you are happy to disable the described clients then run `force_prune(dryRun = false)` to
|permanently remove their unread data.
|
|Once offline clients have been disabled you can continue to run `prune` normally.
|"""
)
def prune(): String = {
val defaultRetention =
consoleEnvironment.environment.config.parameters.retentionPeriodDefaults.sequencer
prune_with_retention_period(defaultRetention.underlying)
}
@Help.Summary(
"Force remove data from the Sequencer including data that may have not been read by offline clients"
)
@Help.Description(
"""Will force pruning up until the default retention period by potentially disabling clients
|that have not yet read data we would like to remove.
|Disabling these clients will prevent them from ever reconnecting to the Domain so should only be
|used if the Domain operator is confident they can be permanently ignored.
|Run with `dryRun = true` to review a description of which clients will be disabled first.
|Run with `dryRun = false` to disable these clients and perform a forced pruning.
|"""
)
def force_prune(dryRun: Boolean): String = {
val defaultRetention =
consoleEnvironment.environment.config.parameters.retentionPeriodDefaults.sequencer
force_prune_with_retention_period(defaultRetention.underlying, dryRun)
}
@Help.Summary("Remove data that has been read up until a custom retention period")
@Help.Description(
"Similar to the above `prune` command but allows specifying a custom retention period"
)
def prune_with_retention_period(retentionPeriod: FiniteDuration): String = {
val status = this.status()
val pruningTimestamp = status.now.minus(retentionPeriod.toJava)
prune_at(pruningTimestamp)
}
@Help.Summary(
"Force removing data from the Sequencer including data that may have not been read by offline clients up until a custom retention period"
)
@Help.Description(
"Similar to the above `force_prune` command but allows specifying a custom retention period"
)
def force_prune_with_retention_period(
retentionPeriod: FiniteDuration,
dryRun: Boolean,
): String = {
val status = this.status()
val pruningTimestamp = status.now.minus(retentionPeriod.toJava)
force_prune_at(pruningTimestamp, dryRun)
}
@Help.Summary("Remove data that has been read up until the specified time")
@Help.Description(
"""Similar to the above `prune` command but allows specifying the exact time at which to prune.
|The command will fail if a client has not yet read and acknowledged some data up to the specified time."""
)
def prune_at(timestamp: CantonTimestamp): String = {
val status = this.status()
val unauthenticatedMembers =
status.unauthenticatedMembersToDisable(
consoleEnvironment.environment.config.parameters.retentionPeriodDefaults.unauthenticatedMembers.toInternal
)
unauthenticatedMembers.foreach(disable_member)
val msg = consoleEnvironment.run {
runner.adminCommand(EnterpriseSequencerAdminCommands.Prune(timestamp))
}
s"$msg. Automatically disabled ${unauthenticatedMembers.size} unauthenticated member clients."
}
@Help.Summary(
"Force removing data from the Sequencer including data that may have not been read by offline clients up until the specified time"
)
@Help.Description(
"Similar to the above `force_prune` command but allows specifying the exact time at which to prune"
)
def force_prune_at(timestamp: CantonTimestamp, dryRun: Boolean): String = {
val initialStatus = status()
val clientsToDisable = initialStatus.clientsPreventingPruning(timestamp)
if (dryRun) {
formatDisableDryRun(timestamp, clientsToDisable)
} else {
disableClients(clientsToDisable)
// check we can now prune for the provided timestamp
val statusAfterDisabling = status()
val safeTimestamp = statusAfterDisabling.safePruningTimestamp
if (safeTimestamp < timestamp)
sys.error(
s"We disabled all clients preventing pruning at $timestamp however the safe timestamp is set to $safeTimestamp"
)
prune_at(timestamp)
}
}
private def disableClients(toDisable: SequencerClients): Unit =
toDisable.members.foreach(disable_member)
private def formatDisableDryRun(
timestamp: CantonTimestamp,
toDisable: SequencerClients,
): String = {
val toDisableText =
toDisable.members.toSeq.map(member => show"- $member").map(m => s" $m (member)").sorted
if (toDisableText.isEmpty) {
show"The Sequencer can be safely pruned for $timestamp without disabling clients"
} else {
val sb = new StringBuilder()
sb.append(s"To prune the Sequencer at $timestamp we will disable:")
toDisableText foreach { item =>
sb.append(System.lineSeparator())
sb.append(item)
}
sb.append(System.lineSeparator())
sb.append(
"To disable these clients to allow for pruning at this point run force_prune with dryRun set to false"
)
sb.toString()
}
}
@Help.Summary("Obtain a timestamp at or near the beginning of sequencer state")
@Help.Description(
"""This command provides insight into the current state of sequencer pruning when called with
|the default value of `index` 1.
|When pruning the sequencer manually via `prune_at` and with the intent to prune in batches, specify
|a value such as 1000 to obtain a pruning timestamp that corresponds to the "end" of the batch."""
)
def locate_pruning_timestamp(
index: PositiveInt = PositiveInt.tryCreate(1)
): Option[CantonTimestamp] =
check(FeatureFlag.Preview) {
consoleEnvironment.run {
runner.adminCommand(LocatePruningTimestampCommand(index))
}
}
}
protected def disable_member(member: Member): Unit
}
trait SequencerAdministrationDisableMember extends ConsoleCommandGroup {
/** Disable the provided member at the sequencer preventing them from reading and writing, and allowing their
* data to be pruned.
*/
@Help.Summary(
"Disable the provided member at the Sequencer that will allow any unread data for them to be removed"
)
@Help.Description("""This will prevent any client for the given member to reconnect the Sequencer
|and allow any unread/unacknowledged data they have to be removed.
|This should only be used if the domain operation is confident the member will never need
|to reconnect as there is no way to re-enable the member.
|To view members using the sequencer run `sequencer.status()`."""")
def disable_member(member: Member): Unit = consoleEnvironment.run {
runner.adminCommand(EnterpriseSequencerAdminCommands.DisableMember(member))
}
}
class SequencerAdministrationGroup(
val runner: AdminCommandRunner,
val consoleEnvironment: ConsoleEnvironment,
val loggerFactory: NamedLoggerFactory,
) extends SequencerAdministrationGroupCommon
with SequencerAdministrationDisableMember {
/** Snapshot based on given snapshot to used as initial state by other sequencer nodes in the process of onboarding.
*/
def snapshot(timestamp: CantonTimestamp): SequencerSnapshot =
consoleEnvironment.run {
runner.adminCommand(EnterpriseSequencerAdminCommands.Snapshot(timestamp))
}
}
trait SequencerAdministrationGroupX extends SequencerAdministrationGroupCommon {
@Help.Summary("Methods used for repairing the node")
object repair extends ConsoleCommandGroup.Impl(this) with SequencerAdministrationDisableMember {}
}

View File

@ -0,0 +1,70 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.admin.api.client.commands.ParticipantAdminCommands
import com.digitalasset.canton.config.RequireTypes.{PositiveInt, PositiveLong}
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlagFilter,
Help,
Helpful,
InstanceReferenceX,
}
import com.digitalasset.canton.crypto.Fingerprint
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.topology.*
import com.digitalasset.canton.topology.transaction.{
SignedTopologyTransactionX,
TopologyChangeOpX,
TrafficControlStateX,
}
import com.digitalasset.canton.traffic.MemberTrafficStatus
class TrafficControlAdministrationGroup(
instance: InstanceReferenceX,
topology: TopologyAdministrationGroupX,
runner: AdminCommandRunner,
override val consoleEnvironment: ConsoleEnvironment,
override val loggerFactory: NamedLoggerFactory,
) extends Helpful
with FeatureFlagFilter {
@Help.Summary("Return the traffic state of the node")
@Help.Description(
"""Use this command to get the traffic state of the node at a given time for a specific domain ID."""
)
def traffic_state(
domainId: DomainId
): MemberTrafficStatus = {
consoleEnvironment.run(
runner.adminCommand(
ParticipantAdminCommands.TrafficControl
.GetTrafficControlState(domainId)
)
)
}
@Help.Summary("Top up traffic for this node")
@Help.Description(
"""Use this command to update the new total traffic limit for the node."""
)
def top_up(
domainId: DomainId,
newTotalTrafficAmount: PositiveLong,
member: Member = instance.id.member,
serial: Option[PositiveInt] = None,
signedBy: Option[Fingerprint] = Some(instance.id.uid.namespace.fingerprint),
): SignedTopologyTransactionX[TopologyChangeOpX, TrafficControlStateX] = {
topology.traffic_control.top_up(
domainId,
newTotalTrafficAmount,
member,
serial,
signedBy,
)
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import com.digitalasset.canton.admin.api.client.commands.SequencerAdminCommands
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlag,
FeatureFlagFilter,
Help,
Helpful,
InstanceReferenceX,
}
import com.digitalasset.canton.domain.sequencing.sequencer.traffic.SequencerTrafficStatus
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.topology.*
class TrafficControlSequencerAdministrationGroup(
instance: InstanceReferenceX,
topology: TopologyAdministrationGroupX,
runner: AdminCommandRunner,
override val consoleEnvironment: ConsoleEnvironment,
override val loggerFactory: NamedLoggerFactory,
) extends TrafficControlAdministrationGroup(
instance,
topology,
runner,
consoleEnvironment,
loggerFactory,
)
with Helpful
with FeatureFlagFilter {
@Help.Summary("Return the traffic state of the given members")
@Help.Description(
"""Use this command to get the traffic state of a list of members."""
)
def traffic_state_of_members(
members: Seq[Member]
): SequencerTrafficStatus = {
consoleEnvironment.run(
runner.adminCommand(
SequencerAdminCommands.GetTrafficControlState(members)
)
)
}
@Help.Summary("Return the traffic state of the all members")
@Help.Description(
"""Use this command to get the traffic state of all members."""
)
def traffic_state_of_all_members: SequencerTrafficStatus = {
check(FeatureFlag.Preview)(
consoleEnvironment.run(
runner.adminCommand(
SequencerAdminCommands.GetTrafficControlState(Seq.empty)
)
)
)
}
}

View File

@ -0,0 +1,613 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console.commands
import cats.data.EitherT
import cats.syntax.either.*
import com.digitalasset.canton.admin.api.client.commands.{TopologyAdminCommands, VaultAdminCommands}
import com.digitalasset.canton.admin.api.client.data.ListKeyOwnersResult
import com.digitalasset.canton.config.RequireTypes.PositiveInt
import com.digitalasset.canton.console.{
AdminCommandRunner,
ConsoleEnvironment,
FeatureFlag,
FeatureFlagFilter,
Help,
Helpful,
InstanceReferenceCommon,
}
import com.digitalasset.canton.crypto.*
import com.digitalasset.canton.crypto.admin.grpc.PrivateKeyMetadata
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.time.Clock
import com.digitalasset.canton.topology.store.TopologyStoreId.AuthorizedStore
import com.digitalasset.canton.topology.{Member, MemberCode}
import com.digitalasset.canton.tracing.TraceContext
import com.digitalasset.canton.util.{BinaryFileUtil, OptionUtil}
import com.digitalasset.canton.version.ProtocolVersion
import com.google.protobuf.ByteString
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission.{OWNER_READ, OWNER_WRITE}
import java.time.Instant
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters.*
class SecretKeyAdministration(
instance: InstanceReferenceCommon,
runner: AdminCommandRunner,
override protected val consoleEnvironment: ConsoleEnvironment,
override protected val loggerFactory: NamedLoggerFactory,
) extends Helpful
with FeatureFlagFilter {
import runner.*
protected def regenerateKey(currentKey: PublicKey, name: Option[String]): PublicKey = {
currentKey match {
case encKey: EncryptionPublicKey =>
instance.keys.secret.generate_encryption_key(
scheme = Some(encKey.scheme),
name = OptionUtil.noneAsEmptyString(name),
)
case signKey: SigningPublicKey =>
instance.keys.secret.generate_signing_key(
scheme = Some(signKey.scheme),
name = OptionUtil.noneAsEmptyString(name),
)
case unknown => throw new IllegalArgumentException(s"Invalid public key type: $unknown")
}
}
@Help.Summary("List keys in private vault")
@Help.Description("""Returns all public keys to the corresponding private keys in the key vault.
|Optional arguments can be used for filtering.""")
def list(
filterFingerprint: String = "",
filterName: String = "",
purpose: Set[KeyPurpose] = Set.empty,
): Seq[PrivateKeyMetadata] =
consoleEnvironment.run {
adminCommand(VaultAdminCommands.ListMyKeys(filterFingerprint, filterName, purpose))
}
@Help.Summary("Generate new public/private key pair for signing and store it in the vault")
@Help.Description(
"""
|The optional name argument allows you to store an associated string for your convenience.
|The scheme can be used to select a key scheme and the default scheme is used if left unspecified."""
)
def generate_signing_key(
name: String = "",
scheme: Option[SigningKeyScheme] = None,
): SigningPublicKey = {
consoleEnvironment.run {
adminCommand(VaultAdminCommands.GenerateSigningKey(name, scheme))
}
}
@Help.Summary("Generate new public/private key pair for encryption and store it in the vault")
@Help.Description(
"""
|The optional name argument allows you to store an associated string for your convenience.
|The scheme can be used to select a key scheme and the default scheme is used if left unspecified."""
)
def generate_encryption_key(
name: String = "",
scheme: Option[EncryptionKeyScheme] = None,
): EncryptionPublicKey = {
consoleEnvironment.run {
adminCommand(VaultAdminCommands.GenerateEncryptionKey(name, scheme))
}
}
@Help.Summary(
"Register the specified KMS signing key in canton storing its public information in the vault"
)
@Help.Description(
"""
|The id for the KMS signing key.
|The optional name argument allows you to store an associated string for your convenience."""
)
def register_kms_signing_key(
kmsKeyId: String,
name: String = "",
): SigningPublicKey = {
consoleEnvironment.run {
adminCommand(VaultAdminCommands.RegisterKmsSigningKey(kmsKeyId, name))
}
}
@Help.Summary(
"Register the specified KMS encryption key in canton storing its public information in the vault"
)
@Help.Description(
"""
|The id for the KMS encryption key.
|The optional name argument allows you to store an associated string for your convenience."""
)
def register_kms_encryption_key(
kmsKeyId: String,
name: String = "",
): EncryptionPublicKey = {
consoleEnvironment.run {
adminCommand(VaultAdminCommands.RegisterKmsEncryptionKey(kmsKeyId, name))
}
}
private def findPublicKey(
fingerprint: String,
topologyAdmin: TopologyAdministrationGroupCommon,
owner: Member,
): PublicKey =
findPublicKeys(topologyAdmin, owner).find(_.fingerprint.unwrap == fingerprint) match {
case Some(key) => key
case None =>
throw new IllegalStateException(
s"The key $fingerprint does not exist"
)
}
@Help.Summary("Rotate a given node's keypair with a new pre-generated KMS keypair")
@Help.Description(
"""Rotates an existing encryption or signing key stored externally in a KMS with a pre-generated
key.
|The fingerprint of the key we want to rotate.
|The id of the new KMS key (e.g. Resource Name)."""
)
def rotate_kms_node_key(fingerprint: String, newKmsKeyId: String): PublicKey = {
val owner = instance.id.member
val currentKey = findPublicKey(fingerprint, instance.topology, owner)
val newKey = currentKey.purpose match {
case KeyPurpose.Signing => instance.keys.secret.register_kms_signing_key(newKmsKeyId)
case KeyPurpose.Encryption => instance.keys.secret.register_kms_encryption_key(newKmsKeyId)
}
// Rotate the key for the node in the topology management
instance.topology.owner_to_key_mappings.rotate_key(
instance,
owner,
currentKey,
newKey,
)
newKey
}
@Help.Summary("Rotate a node's public/private key pair")
@Help.Description(
"""Rotates an existing encryption or signing key. NOTE: A namespace root or intermediate
signing key CANNOT be rotated by this command.
|The fingerprint of the key we want to rotate."""
)
def rotate_node_key(fingerprint: String, name: Option[String] = None): PublicKey = {
val owner = instance.id.member
val currentKey = findPublicKey(fingerprint, instance.topology, owner)
val newKey = name match {
case Some(_) => regenerateKey(currentKey, name)
case None =>
regenerateKey(
currentKey,
generateNewNameForRotatedKey(fingerprint, consoleEnvironment.environment.clock),
)
}
// Rotate the key for the node in the topology management
instance.topology.owner_to_key_mappings.rotate_key(
instance,
owner,
currentKey,
newKey,
)
newKey
}
@Help.Summary("Rotate the node's public/private key pairs")
@Help.Description(
"""
|For a participant node it rotates the signing and encryption key pair.
|For a domain or domain manager node it rotates the signing key pair as those nodes do not have an encryption key pair.
|For a sequencer or mediator node use `rotate_node_keys` with a domain manager reference as an argument.
|NOTE: Namespace root or intermediate signing keys are NOT rotated by this command."""
)
def rotate_node_keys(): Unit = {
val owner = instance.id.member
// Find the current keys
val currentKeys = findPublicKeys(instance.topology, owner)
currentKeys.foreach { currentKey =>
val newKey =
regenerateKey(
currentKey,
generateNewNameForRotatedKey(
currentKey.fingerprint.unwrap,
consoleEnvironment.environment.clock,
),
)
// Rotate the key for the node in the topology management
instance.topology.owner_to_key_mappings.rotate_key(
instance,
owner,
currentKey,
newKey,
)
}
}
/** Helper to find public keys for topology/x shared between community and enterprise
*/
protected def findPublicKeys(
topologyAdmin: TopologyAdministrationGroupCommon,
owner: Member,
): Seq[PublicKey] =
topologyAdmin match {
case t: TopologyAdministrationGroup =>
t.owner_to_key_mappings
.list(
filterStore = AuthorizedStore.filterName,
filterKeyOwnerUid = owner.filterString,
filterKeyOwnerType = Some(owner.code),
)
.map(_.item.key)
case tx: TopologyAdministrationGroupX =>
tx.owner_to_key_mappings
.list(
filterStore = AuthorizedStore.filterName,
filterKeyOwnerUid = owner.filterString,
filterKeyOwnerType = Some(owner.code),
)
.flatMap(_.item.keys)
case _ =>
throw new IllegalStateException(
"Impossible to encounter topology admin group besides X and non-X"
)
}
/** Helper to name new keys generated during a rotation with a ...-rotated-<timestamp> tag to better identify
* the new keys after a rotation
*/
protected def generateNewNameForRotatedKey(
currentKeyId: String,
clock: Clock,
): Option[String] = {
val keyName = instance.keys.secret
.list()
.find(_.publicKey.fingerprint.unwrap == currentKeyId)
.flatMap(_.name)
val rotatedKeyRegExp = "(.*-rotated).*".r
keyName.map(_.unwrap) match {
case Some(rotatedKeyRegExp(currentName)) =>
Some(s"$currentName-${clock.now.show}")
case Some(currentName) =>
Some(s"$currentName-rotated-${clock.now.show}")
case None => None
}
}
@Help.Summary("Change the wrapper key for encrypted private keys store")
@Help.Description(
"""Change the wrapper key (e.g. AWS KMS key) being used to encrypt the private keys in the store.
|newWrapperKeyId: The optional new wrapper key id to be used. If the wrapper key id is empty Canton will generate a new key based on the current configuration."""
)
def rotate_wrapper_key(
newWrapperKeyId: String = ""
): Unit = {
consoleEnvironment.run {
adminCommand(VaultAdminCommands.RotateWrapperKey(newWrapperKeyId))
}
}
@Help.Summary("Get the wrapper key id that is used for the encrypted private keys store")
def get_wrapper_key_id(): String = {
consoleEnvironment.run {
adminCommand(VaultAdminCommands.GetWrapperKeyId())
}
}
@Help.Summary("Upload (load and import) a key pair from file")
def upload(filename: String, name: Option[String]): Unit = {
val keyPair = BinaryFileUtil.tryReadByteStringFromFile(filename)
upload(keyPair, name)
}
@Help.Summary("Upload a key pair")
def upload(
pairBytes: ByteString,
name: Option[String],
): Unit =
consoleEnvironment.run {
adminCommand(
VaultAdminCommands.ImportKeyPair(pairBytes, name)
)
}
// TODO(i13613): Remove feature flag
@Help.Summary("Download key pair", FeatureFlag.Preview)
@Help.Description(
"""Download the key pair with the private and public key in its binary representation.
|fingerprint: The identifier of the key pair to download
|protocolVersion: The (optional) protocol version that defines the serialization of the key pair"""
)
def download(
fingerprint: Fingerprint,
protocolVersion: ProtocolVersion = ProtocolVersion.latest,
): ByteString = {
check(FeatureFlag.Preview) {
consoleEnvironment.run {
adminCommand(
VaultAdminCommands.ExportKeyPair(fingerprint, protocolVersion)
)
}
}
}
protected def writeToFile(outputFile: String, bytes: ByteString): Unit = {
val file = new File(outputFile)
file.createNewFile()
// only current user has permissions with the file
try {
Files.setPosixFilePermissions(file.toPath, Set(OWNER_READ, OWNER_WRITE).asJava)
} catch {
// the above will throw on non-posix systems such as windows
case _: UnsupportedOperationException =>
}
BinaryFileUtil.writeByteStringToFile(outputFile, bytes)
}
@Help.Summary("Download key pair and save it to a file")
def download_to(
fingerprint: Fingerprint,
outputFile: String,
protocolVersion: ProtocolVersion = ProtocolVersion.latest,
): Unit = {
writeToFile(outputFile, download(fingerprint, protocolVersion))
}
@Help.Summary("Delete private key")
def delete(fingerprint: Fingerprint, force: Boolean = false): Unit = {
def deleteKey(): Unit =
consoleEnvironment.run {
adminCommand(
VaultAdminCommands.DeleteKeyPair(fingerprint)
)
}
if (force)
deleteKey()
else {
println(
s"Are you sure you want to delete the private key with fingerprint $fingerprint? yes/no"
)
println(s"This action is irreversible and can have undesired effects if done carelessly.")
print("> ")
val answer = Option(scala.io.StdIn.readLine())
if (answer.exists(_.toLowerCase == "yes")) deleteKey()
}
}
}
class PublicKeyAdministration(
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
) extends Helpful {
import runner.*
private def defaultLimit: PositiveInt =
consoleEnvironment.environment.config.parameters.console.defaultLimit
@Help.Summary("Upload public key")
@Help.Description(
"""Import a public key and store it together with a name used to provide some context to that key."""
)
def upload(keyBytes: ByteString, name: Option[String]): Fingerprint = consoleEnvironment.run {
adminCommand(
VaultAdminCommands.ImportPublicKey(keyBytes, name)
)
}
@Help.Summary("Upload public key")
@Help.Summary(
"Load a public key from a file and store it together with a name used to provide some context to that key."
)
def upload(filename: String, name: Option[String]): Fingerprint = consoleEnvironment.run {
BinaryFileUtil.readByteStringFromFile(filename) match {
case Right(bytes) => adminCommand(VaultAdminCommands.ImportPublicKey(bytes, name))
case Left(err) => throw new IllegalArgumentException(err)
}
}
@Help.Summary("Download public key")
def download(
fingerprint: Fingerprint,
protocolVersion: ProtocolVersion = ProtocolVersion.latest,
): ByteString = {
val keys = list(fingerprint.unwrap)
if (keys.sizeCompare(1) == 0) { // vector doesn't like matching on Nil
val key = keys.headOption.getOrElse(sys.error("no key"))
key.publicKey.toByteString(protocolVersion)
} else {
if (keys.isEmpty) throw new IllegalArgumentException(s"no key found for [$fingerprint]")
else
throw new IllegalArgumentException(
s"found multiple results for [$fingerprint]: ${keys.map(_.publicKey.fingerprint)}"
)
}
}
@Help.Summary("Download public key and save it to a file")
def download_to(
fingerprint: Fingerprint,
outputFile: String,
protocolVersion: ProtocolVersion = ProtocolVersion.latest,
): Unit = {
BinaryFileUtil.writeByteStringToFile(
outputFile,
download(fingerprint, protocolVersion),
)
}
@Help.Summary("List public keys in registry")
@Help.Description("""Returns all public keys that have been added to the key registry.
Optional arguments can be used for filtering.""")
def list(filterFingerprint: String = "", filterContext: String = ""): Seq[PublicKeyWithName] =
consoleEnvironment.run {
adminCommand(VaultAdminCommands.ListPublicKeys(filterFingerprint, filterContext))
}
@Help.Summary("List active owners with keys for given search arguments.")
@Help.Description("""This command allows deep inspection of the topology state.
|The response includes the public keys.
|Optional filterKeyOwnerType type can be 'ParticipantId.Code' , 'MediatorId.Code','SequencerId.Code', 'DomainTopologyManagerId.Code'.
|""")
def list_owners(
filterKeyOwnerUid: String = "",
filterKeyOwnerType: Option[MemberCode] = None,
filterDomain: String = "",
asOf: Option[Instant] = None,
limit: PositiveInt = defaultLimit,
): Seq[ListKeyOwnersResult] = consoleEnvironment.run {
adminCommand(
TopologyAdminCommands.Aggregation
.ListKeyOwners(filterDomain, filterKeyOwnerType, filterKeyOwnerUid, asOf, limit)
)
}
@Help.Summary("List keys for given keyOwner.")
@Help.Description(
"""This command is a convenience wrapper for `list_key_owners`, taking an explicit keyOwner as search argument.
|The response includes the public keys."""
)
def list_by_owner(
keyOwner: Member,
filterDomain: String = "",
asOf: Option[Instant] = None,
limit: PositiveInt = defaultLimit,
): Seq[ListKeyOwnersResult] = consoleEnvironment.run {
adminCommand(
TopologyAdminCommands.Aggregation.ListKeyOwners(
filterDomain = filterDomain,
filterKeyOwnerType = Some(keyOwner.code),
filterKeyOwnerUid = keyOwner.uid.toProtoPrimitive,
asOf,
limit,
)
)
}
}
class KeyAdministrationGroup(
instance: InstanceReferenceCommon,
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
loggerFactory: NamedLoggerFactory,
) extends Helpful {
private lazy val publicAdmin =
new PublicKeyAdministration(runner, consoleEnvironment)
private lazy val secretAdmin =
new SecretKeyAdministration(instance, runner, consoleEnvironment, loggerFactory)
@Help.Summary("Manage public keys")
@Help.Group("Public keys")
def public: PublicKeyAdministration = publicAdmin
@Help.Summary("Manage secret keys")
@Help.Group("Secret keys")
def secret: SecretKeyAdministration = secretAdmin
}
class LocalSecretKeyAdministration(
instance: InstanceReferenceCommon,
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
crypto: => Crypto,
loggerFactory: NamedLoggerFactory,
)(implicit executionContext: ExecutionContext)
extends SecretKeyAdministration(instance, runner, consoleEnvironment, loggerFactory) {
private def run[V](eitherT: EitherT[Future, String, V], action: String): V = {
import TraceContext.Implicits.Empty.*
consoleEnvironment.environment.config.parameters.timeouts.processing.default
.await(action)(eitherT.value) match {
case Left(error) =>
throw new IllegalArgumentException(s"Problem while $action. Error: $error")
case Right(value) => value
}
}
@Help.Summary("Download key pair")
override def download(
fingerprint: Fingerprint,
protocolVersion: ProtocolVersion = ProtocolVersion.latest,
): ByteString =
TraceContext.withNewTraceContext { implicit traceContext =>
val cmd = for {
cryptoPrivateStore <- crypto.cryptoPrivateStore.toExtended
.toRight(
"The selected crypto provider does not support exporting of private keys."
)
.toEitherT[Future]
privateKey <- cryptoPrivateStore
.exportPrivateKey(fingerprint)
.leftMap(_.toString)
.subflatMap(_.toRight(s"no private key found for [$fingerprint]"))
.leftMap(err => s"Error retrieving private key [$fingerprint] $err")
publicKey <- crypto.cryptoPublicStore
.publicKey(fingerprint)
.leftMap(_.toString)
.subflatMap(_.toRight(s"no public key found for [$fingerprint]"))
.leftMap(err => s"Error retrieving public key [$fingerprint] $err")
keyPair: CryptoKeyPair[PublicKey, PrivateKey] = (publicKey, privateKey) match {
case (pub: SigningPublicKey, pkey: SigningPrivateKey) =>
new SigningKeyPair(pub, pkey)
case (pub: EncryptionPublicKey, pkey: EncryptionPrivateKey) =>
new EncryptionKeyPair(pub, pkey)
case _ => sys.error("public and private keys must have same purpose")
}
keyPairBytes = keyPair.toByteString(protocolVersion)
} yield keyPairBytes
run(cmd, "exporting key pair")
}
@Help.Summary("Download key pair and save it to a file")
override def download_to(
fingerprint: Fingerprint,
outputFile: String,
protocolVersion: ProtocolVersion = ProtocolVersion.latest,
): Unit =
run(
EitherT.rightT(writeToFile(outputFile, download(fingerprint, protocolVersion))),
"saving key pair to file",
)
}
class LocalKeyAdministrationGroup(
instance: InstanceReferenceCommon,
runner: AdminCommandRunner,
consoleEnvironment: ConsoleEnvironment,
crypto: => Crypto,
loggerFactory: NamedLoggerFactory,
)(implicit executionContext: ExecutionContext)
extends KeyAdministrationGroup(instance, runner, consoleEnvironment, loggerFactory) {
private lazy val localSecretAdmin: LocalSecretKeyAdministration =
new LocalSecretKeyAdministration(instance, runner, consoleEnvironment, crypto, loggerFactory)
@Help.Summary("Manage secret keys")
@Help.Group("Secret keys")
override def secret: LocalSecretKeyAdministration = localSecretAdmin
}

View File

@ -0,0 +1,45 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import cats.syntax.either.*
import cats.syntax.functorFilter.*
import com.digitalasset.canton.DiscardOps
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.logging.ErrorLoggingContext
package object commands {
/** Runs every body, even if some of them fail with a `CommandExecutionFailedException`.
* Succeeds, if all bodies succeed.
* If some body throws a `Throwable` other than `CommandExecutionFailedException`, the execution terminates immediately with that exception.
* If some body throws a `CommandExecutionFailedException`, subsequent bodies are still executed and afterwards the
* methods throws a `CommandExecutionFailedException`, preferring `CantonInternalErrors` over `CommandFailure`.
*/
private[commands] def runEvery[A](bodies: Seq[() => Unit]): Unit = {
val exceptions = bodies.mapFilter(body =>
try {
body()
None
} catch {
case e: CommandFailure => Some(e)
case e: CantonInternalError => Some(e)
}
)
// It is ok to discard all except one exceptions, because:
// - The exceptions do not have meaningful messages. Error messages are logged instead.
// - The exception have all the same stack trace.
exceptions.collectFirst { case e: CantonInternalError => throw e }.discard
exceptions.headOption.foreach(throw _)
}
private[commands] def timestampFromInstant(
instant: java.time.Instant
)(implicit loggingContext: ErrorLoggingContext): CantonTimestamp =
CantonTimestamp.fromInstant(instant).valueOr { err =>
loggingContext.logger.error(err)(loggingContext.traceContext)
throw new CommandFailure()
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton
import com.digitalasset.canton.console.CommandErrors.GenericCommandError
/** General `console` utilities
*/
package object console {
/** Turn a either into a command result.
* Left is considered an error, Right is successful.
*/
implicit class EitherToCommandResultExtensions[A, B](either: Either[A, B]) {
def toResult(errorDescription: A => String): ConsoleCommandResult[B] =
either.fold[ConsoleCommandResult[B]](
err => GenericCommandError(errorDescription(err)),
CommandSuccessful[B],
)
def toResult[Result](
errorDescription: A => String,
resultMap: B => Result,
): ConsoleCommandResult[Result] =
either.fold[ConsoleCommandResult[Result]](
err => GenericCommandError(errorDescription(err)),
result => CommandSuccessful(resultMap(result)),
)
}
/** Turn an either where Left is a error message into a ConsoleCommandResult.
*/
implicit class StringErrorEitherToCommandResultExtensions[A](either: Either[String, A]) {
def toResult: ConsoleCommandResult[A] =
either.fold[ConsoleCommandResult[A]](GenericCommandError, CommandSuccessful[A])
}
/** Strip the Object suffix from the name of the provided class
*/
def objectClassNameWithoutSuffix(c: Class[_]): String =
c.getName.stripSuffix("$").replace('$', '.')
}

View File

@ -0,0 +1,147 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.environment
import com.digitalasset.canton.admin.api.client.data.CommunityCantonStatus
import com.digitalasset.canton.config.{CantonCommunityConfig, TestingConfigInternal}
import com.digitalasset.canton.console.{
CantonHealthAdministration,
CommunityCantonHealthAdministration,
CommunityHealthDumpGenerator,
CommunityLocalDomainReference,
CommunityRemoteDomainReference,
ConsoleEnvironment,
ConsoleEnvironmentBinding,
ConsoleGrpcAdminCommandRunner,
ConsoleOutput,
DomainReference,
FeatureFlag,
GrpcAdminCommandRunner,
HealthDumpGenerator,
Help,
LocalDomainReference,
LocalInstanceReferenceCommon,
LocalParticipantReference,
NodeReferences,
StandardConsoleOutput,
}
import com.digitalasset.canton.domain.DomainNodeBootstrap
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.participant.{ParticipantNodeBootstrap, ParticipantNodeBootstrapX}
import com.digitalasset.canton.resource.{CommunityDbMigrationsFactory, DbMigrationsFactory}
class CommunityEnvironment(
override val config: CantonCommunityConfig,
override val testingConfig: TestingConfigInternal,
override val loggerFactory: NamedLoggerFactory,
) extends Environment {
override type Config = CantonCommunityConfig
override protected val participantNodeFactory
: ParticipantNodeBootstrap.Factory[Config#ParticipantConfigType, ParticipantNodeBootstrap] =
ParticipantNodeBootstrap.CommunityParticipantFactory
override protected val participantNodeFactoryX
: ParticipantNodeBootstrap.Factory[Config#ParticipantConfigType, ParticipantNodeBootstrapX] =
ParticipantNodeBootstrapX.CommunityParticipantFactory
override protected val domainFactory: DomainNodeBootstrap.Factory[Config#DomainConfigType] =
DomainNodeBootstrap.CommunityDomainFactory
override type Console = CommunityConsoleEnvironment
override protected def _createConsole(
consoleOutput: ConsoleOutput,
createAdminCommandRunner: ConsoleEnvironment => ConsoleGrpcAdminCommandRunner,
): CommunityConsoleEnvironment =
new CommunityConsoleEnvironment(this, consoleOutput, createAdminCommandRunner)
override protected lazy val migrationsFactory: DbMigrationsFactory =
new CommunityDbMigrationsFactory(loggerFactory)
override def isEnterprise: Boolean = false
def createHealthDumpGenerator(
commandRunner: GrpcAdminCommandRunner
): HealthDumpGenerator[CommunityCantonStatus] = {
new CommunityHealthDumpGenerator(this, commandRunner)
}
}
object CommunityEnvironmentFactory extends EnvironmentFactory[CommunityEnvironment] {
override def create(
config: CantonCommunityConfig,
loggerFactory: NamedLoggerFactory,
testingConfigInternal: TestingConfigInternal,
): CommunityEnvironment =
new CommunityEnvironment(config, testingConfigInternal, loggerFactory)
}
class CommunityConsoleEnvironment(
val environment: CommunityEnvironment,
val consoleOutput: ConsoleOutput = StandardConsoleOutput,
protected val createAdminCommandRunner: ConsoleEnvironment => ConsoleGrpcAdminCommandRunner =
new ConsoleGrpcAdminCommandRunner(_),
) extends ConsoleEnvironment {
override type Env = CommunityEnvironment
override type DomainLocalRef = CommunityLocalDomainReference
override type DomainRemoteRef = CommunityRemoteDomainReference
override type Status = CommunityCantonStatus
private lazy val health_ = new CommunityCantonHealthAdministration(this)
override protected val consoleEnvironmentBindings = new ConsoleEnvironmentBinding()
@Help.Summary("Environment health inspection")
@Help.Group("Health")
override def health: CantonHealthAdministration[Status] =
health_
override def startupOrderPrecedence(instance: LocalInstanceReferenceCommon): Int =
instance match {
case _: LocalDomainReference => 1
case _: LocalParticipantReference => 2
case _ => 3
}
override protected def createDomainReference(name: String): DomainLocalRef =
new CommunityLocalDomainReference(this, name, environment.executionContext)
override protected def createRemoteDomainReference(name: String): DomainRemoteRef =
new CommunityRemoteDomainReference(this, name)
override protected def domainsTopLevelValue(
h: TopLevelValue.Partial,
domains: NodeReferences[
DomainReference,
CommunityRemoteDomainReference,
CommunityLocalDomainReference,
],
): TopLevelValue[
NodeReferences[DomainReference, CommunityRemoteDomainReference, CommunityLocalDomainReference]
] =
h(domains)
override protected def localDomainTopLevelValue(
h: TopLevelValue.Partial,
d: CommunityLocalDomainReference,
): TopLevelValue[CommunityLocalDomainReference] =
h(d)
override protected def remoteDomainTopLevelValue(
h: TopLevelValue.Partial,
d: CommunityRemoteDomainReference,
): TopLevelValue[CommunityRemoteDomainReference] =
h(d)
override protected def localDomainHelpItems(
scope: Set[FeatureFlag],
localDomain: CommunityLocalDomainReference,
): Seq[Help.Item] =
Help.getItems(localDomain, baseTopic = Seq("$domain"), scope = scope)
override protected def remoteDomainHelpItems(
scope: Set[FeatureFlag],
remoteDomain: CommunityRemoteDomainReference,
): Seq[Help.Item] =
Help.getItems(remoteDomain, baseTopic = Seq("$domain"), scope = scope)
}

View File

@ -0,0 +1,627 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.environment
import cats.data.EitherT
import cats.instances.option.*
import cats.syntax.apply.*
import cats.syntax.either.*
import cats.syntax.foldable.*
import cats.syntax.traverse.*
import com.daml.grpc.adapter.ExecutionSequencerFactory
import com.digitalasset.canton.concurrent.*
import com.digitalasset.canton.config.*
import com.digitalasset.canton.console.{
ConsoleEnvironment,
ConsoleGrpcAdminCommandRunner,
ConsoleOutput,
GrpcAdminCommandRunner,
HealthDumpGenerator,
StandardConsoleOutput,
}
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.domain.DomainNodeBootstrap
import com.digitalasset.canton.environment.CantonNodeBootstrap.HealthDumpFunction
import com.digitalasset.canton.environment.Environment.*
import com.digitalasset.canton.environment.ParticipantNodes.{ParticipantNodesOld, ParticipantNodesX}
import com.digitalasset.canton.health.{HealthCheck, HealthServer}
import com.digitalasset.canton.lifecycle.Lifecycle
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.metrics.MetricsConfig.Prometheus
import com.digitalasset.canton.metrics.MetricsFactory
import com.digitalasset.canton.participant.domain.DomainConnectionConfig
import com.digitalasset.canton.participant.{
ParticipantNode,
ParticipantNodeBootstrap,
ParticipantNodeBootstrapCommon,
ParticipantNodeBootstrapX,
ParticipantNodeCommon,
}
import com.digitalasset.canton.resource.DbMigrationsFactory
import com.digitalasset.canton.sequencing.SequencerConnections
import com.digitalasset.canton.telemetry.{ConfiguredOpenTelemetry, OpenTelemetryFactory}
import com.digitalasset.canton.time.EnrichedDurations.*
import com.digitalasset.canton.time.*
import com.digitalasset.canton.tracing.TraceContext.withNewTraceContext
import com.digitalasset.canton.tracing.{NoTracing, TraceContext, TracerProvider}
import com.digitalasset.canton.util.FutureInstances.parallelFuture
import com.digitalasset.canton.util.{MonadUtil, PekkoUtil, SingleUseCell}
import com.digitalasset.canton.{DiscardOps, DomainAlias}
import com.google.common.annotations.VisibleForTesting
import io.circe.Encoder
import io.opentelemetry.api.trace.Tracer
import org.apache.pekko.actor.ActorSystem
import org.slf4j.bridge.SLF4JBridgeHandler
import java.util.concurrent.ScheduledExecutorService
import scala.collection.mutable.ListBuffer
import scala.concurrent.{Future, blocking}
import scala.util.control.NonFatal
/** Holds all significant resources held by this process.
*/
trait Environment extends NamedLogging with AutoCloseable with NoTracing {
type Config <: CantonConfig
type Console <: ConsoleEnvironment
val config: Config
val testingConfig: TestingConfigInternal
val loggerFactory: NamedLoggerFactory
lazy val configuredOpenTelemetry: ConfiguredOpenTelemetry = {
val isPrometheusEnabled = config.monitoring.metrics.reporters.exists {
case _: Prometheus => true
case _ => false
}
OpenTelemetryFactory.initializeOpenTelemetry(
testingConfig.initializeGlobalOpenTelemetry,
isPrometheusEnabled,
config.monitoring.tracing.tracer,
config.monitoring.metrics.histograms,
loggerFactory,
)
}
// public for buildDocs task to be able to construct a fake participant and domain to document available metrics via reflection
lazy val metricsFactory: MetricsFactory =
MetricsFactory.forConfig(
config.monitoring.metrics,
configuredOpenTelemetry.openTelemetry,
testingConfig.metricsFactoryType,
)
protected def participantNodeFactory
: ParticipantNodeBootstrap.Factory[Config#ParticipantConfigType, ParticipantNodeBootstrap]
protected def participantNodeFactoryX
: ParticipantNodeBootstrap.Factory[Config#ParticipantConfigType, ParticipantNodeBootstrapX]
protected def domainFactory: DomainNodeBootstrap.Factory[Config#DomainConfigType]
protected def migrationsFactory: DbMigrationsFactory
def isEnterprise: Boolean
def createConsole(
consoleOutput: ConsoleOutput = StandardConsoleOutput,
createAdminCommandRunner: ConsoleEnvironment => ConsoleGrpcAdminCommandRunner =
new ConsoleGrpcAdminCommandRunner(_),
): Console = {
val console = _createConsole(consoleOutput, createAdminCommandRunner)
healthDumpGenerator
.putIfAbsent(createHealthDumpGenerator(console.grpcAdminCommandRunner))
.discard
console
}
protected def _createConsole(
consoleOutput: ConsoleOutput = StandardConsoleOutput,
createAdminCommandRunner: ConsoleEnvironment => ConsoleGrpcAdminCommandRunner =
new ConsoleGrpcAdminCommandRunner(_),
): Console
protected def createHealthDumpGenerator(
commandRunner: GrpcAdminCommandRunner
): HealthDumpGenerator[_]
/* We can't reliably use the health administration instance of the console because:
* 1) it's tied to the console environment, which we don't have access to yet when the environment is instantiated
* 2) there might never be a console environment when running in daemon mode
* Therefore we create an immutable lazy value for the health administration that can be set either with the console
* health admin when/if it gets created, or with a headless health admin, whichever comes first.
*/
private val healthDumpGenerator = new SingleUseCell[HealthDumpGenerator[_]]
// Function passed down to the node boostrap used to generate a health dump file
val writeHealthDumpToFile: HealthDumpFunction = () =>
Future {
healthDumpGenerator
.getOrElse {
val tracerProvider =
TracerProvider.Factory(configuredOpenTelemetry, "admin_command_runner")
implicit val tracer: Tracer = tracerProvider.tracer
val commandRunner = new GrpcAdminCommandRunner(this, config.parameters.timeouts.console)
val newGenerator = createHealthDumpGenerator(commandRunner)
val previous = healthDumpGenerator.putIfAbsent(newGenerator)
previous match {
// If somehow the cell was set concurrently in the meantime, close the newly created command runner and use
// the existing one
case Some(value) =>
commandRunner.close()
value
case None =>
newGenerator
}
}
.generateHealthDump(
better.files.File.newTemporaryFile(
prefix = "canton-remote-health-dump"
)
)
}
installJavaUtilLoggingBridge()
logger.debug(config.portDescription)
implicit val scheduler: ScheduledExecutorService =
Threading.singleThreadScheduledExecutor(
loggerFactory.threadName + "-env-scheduler",
noTracingLogger,
)
private val numThreads = Threading.detectNumberOfThreads(noTracingLogger)
implicit val executionContext: ExecutionContextIdlenessExecutorService =
Threading.newExecutionContext(
loggerFactory.threadName + "-env-execution-context",
noTracingLogger,
Option.when(config.monitoring.metrics.reportExecutionContextMetrics)(
metricsFactory.executionServiceMetrics
),
numThreads,
)
private val deadlockConfig = config.monitoring.deadlockDetection
protected def timeouts: ProcessingTimeout = config.parameters.timeouts.processing
protected val futureSupervisor =
if (config.monitoring.logSlowFutures)
new FutureSupervisor.Impl(timeouts.slowFutureWarn)
else FutureSupervisor.Noop
private val monitorO = if (deadlockConfig.enabled) {
val mon = new ExecutionContextMonitor(
loggerFactory,
deadlockConfig.interval.toInternal,
deadlockConfig.warnInterval.toInternal,
timeouts,
)
mon.monitor(executionContext)
Some(mon)
} else None
implicit val actorSystem: ActorSystem = PekkoUtil.createActorSystem(loggerFactory.threadName)
implicit val executionSequencerFactory: ExecutionSequencerFactory =
PekkoUtil.createExecutionSequencerFactory(
loggerFactory.threadName + "-admin-workflow-services",
// don't log the number of threads twice, as we log it already when creating the first pool
NamedLogging.noopNoTracingLogger,
)
// additional closeables
private val userCloseables = ListBuffer[AutoCloseable]()
/** Sim-clock if environment is using static time
*/
val simClock: Option[DelegatingSimClock] = config.parameters.clock match {
case ClockConfig.SimClock =>
logger.info("Starting environment with sim-clock")
Some(
new DelegatingSimClock(
() =>
runningNodes.map(_.clock).collect { case c: SimClock =>
c
},
loggerFactory = loggerFactory,
)
)
case ClockConfig.WallClock(_) => None
case ClockConfig.RemoteClock(_) => None
}
val clock: Clock = simClock.getOrElse(createClock(None))
protected def createClock(nodeTypeAndName: Option[(String, String)]): Clock = {
val clockLoggerFactory = nodeTypeAndName.fold(loggerFactory) { case (nodeType, name) =>
loggerFactory.append(nodeType, name)
}
config.parameters.clock match {
case ClockConfig.SimClock =>
val parent = simClock.getOrElse(sys.error("This should not happen"))
val clock = new SimClock(
parent.start,
clockLoggerFactory,
)
clock.advanceTo(parent.now)
clock
case ClockConfig.RemoteClock(clientConfig) =>
new RemoteClock(clientConfig, config.parameters.timeouts.processing, clockLoggerFactory)
case ClockConfig.WallClock(skewW) =>
val skewMs = skewW.asJava.toMillis
val tickTock =
if (skewMs == 0) TickTock.Native
else new TickTock.RandomSkew(Math.min(skewMs, Int.MaxValue).toInt)
new WallClock(timeouts, clockLoggerFactory, tickTock)
}
}
private val testingTimeService = new TestingTimeService(clock, () => simClocks)
protected lazy val healthCheck: Option[HealthCheck] = config.monitoring.health.map(config =>
HealthCheck(config.check, metricsFactory.health, timeouts, loggerFactory)(this)
)
private val healthServer =
(healthCheck, config.monitoring.health).mapN { case (check, config) =>
new HealthServer(check, config.server.address, config.server.port, timeouts, loggerFactory)
}
private val envQueueSize = () => executionContext.queueSize
metricsFactory.forEnv.registerExecutionContextQueueSize(envQueueSize)
lazy val domains =
new DomainNodes(
createDomain,
migrationsFactory,
timeouts,
config.domainsByString,
config.domainNodeParametersByString,
loggerFactory,
)
lazy val participants =
new ParticipantNodesOld[Config#ParticipantConfigType](
createParticipant,
migrationsFactory,
timeouts,
config.participantsByString,
config.participantNodeParametersByString,
loggerFactory,
)
lazy val participantsX =
new ParticipantNodesX[Config#ParticipantConfigType](
createParticipantX,
migrationsFactory,
timeouts,
config.participantsByStringX,
config.participantNodeParametersByString,
loggerFactory,
)
// convenient grouping of all node collections for performing operations
// intentionally defined in the order we'd like to start them
protected def allNodes: List[Nodes[CantonNode, CantonNodeBootstrap[CantonNode]]] =
List(domains, participants, participantsX)
private def runningNodes: Seq[CantonNodeBootstrap[CantonNode]] = allNodes.flatMap(_.running)
private def autoConnectLocalNodes(): Either[StartupError, Unit] = {
// TODO(#14048) extend this to x-nodes
val activeDomains = domains.running
.filter(_.isActive)
.filter(_.config.topology.open)
def toDomainConfig(domain: DomainNodeBootstrap): Either[StartupError, DomainConnectionConfig] =
(for {
connection <- domain.config.sequencerConnectionConfig.toConnection
name <- DomainAlias.create(domain.name.unwrap)
sequencerConnections = SequencerConnections.single(connection)
} yield DomainConnectionConfig(name, sequencerConnections)).leftMap(err =>
StartFailed(domain.name.unwrap, s"Can not parse config for auto-connect: ${err}")
)
val connectParticipants =
participants.running.filter(_.isActive).flatMap(x => x.getNode.map((x.name, _)).toList)
def connect(
name: String,
node: ParticipantNode,
configs: Seq[DomainConnectionConfig],
): Either[StartupError, Unit] =
configs.traverse_ { config =>
val connectET =
node
.autoConnectLocalDomain(config)
.leftMap(err => StartFailed(name, err.toString))
.onShutdown(Left(StartFailed(name, "aborted due to shutdown")))
this.config.parameters.timeouts.processing.unbounded
.await("auto-connect to local domain")(connectET.value)
}
logger.info(s"Auto-connecting local participants ${connectParticipants
.map(_._1.unwrap)} to local domains ${activeDomains.map(_.name.unwrap)}")
activeDomains
.traverse(toDomainConfig)
.traverse_(config =>
connectParticipants.traverse_ { case (name, node) => connect(name.unwrap, node, config) }
)
}
/** Try to startup all nodes in the configured environment and reconnect them to one another.
* The first error will prevent further nodes from being started.
* If an error is returned previously started nodes will not be stopped.
*/
def startAndReconnect(autoConnectLocal: Boolean): Either[StartupError, Unit] =
withNewTraceContext { implicit traceContext =>
if (config.parameters.manualStart) {
logger.info("Manual start requested.")
Right(())
} else {
logger.info("Automatically starting all instances")
val startup = for {
_ <- startAll()
_ <- reconnectParticipants
_ <- if (autoConnectLocal) autoConnectLocalNodes() else Right(())
} yield writePortsFile()
// log results
startup
.bimap(
error => logger.error(s"Failed to start ${error.name}: ${error.message}"),
_ => logger.info("Successfully started all nodes"),
)
.discard
startup
}
}
private def writePortsFile()(implicit
traceContext: TraceContext
): Unit = {
final case class ParticipantApis(ledgerApi: Int, adminApi: Int)
config.parameters.portsFile.foreach { portsFile =>
val items = participants.running.map { node =>
(
node.name.unwrap,
ParticipantApis(node.config.ledgerApi.port.unwrap, node.config.adminApi.port.unwrap),
)
}.toMap
import io.circe.syntax.*
implicit val encoder: Encoder[ParticipantApis] =
Encoder.forProduct2("ledgerApi", "adminApi") { apis =>
(apis.ledgerApi, apis.adminApi)
}
val out = items.asJson.spaces2
try {
better.files.File(portsFile).overwrite(out)
} catch {
case NonFatal(ex) =>
logger.warn(s"Failed to write to port file ${portsFile}. Will ignore the error", ex)
}
}
}
private def reconnectParticipants(implicit
traceContext: TraceContext
): Either[StartupError, Unit] = {
def reconnect(
instance: CantonNodeBootstrap[ParticipantNodeCommon] & ParticipantNodeBootstrapCommon
): EitherT[Future, StartupError, Unit] = {
instance.getNode match {
case None =>
// should not happen, but if it does, display at least a warning.
if (instance.config.init.autoInit) {
logger.error(
s"Auto-initialisation failed or was too slow for ${instance.name}. Will not automatically re-connect to domains."
)
}
EitherT.rightT(())
case Some(node) =>
node
.reconnectDomainsIgnoreFailures()
.leftMap(err => StartFailed(instance.name.unwrap, err.toString))
.onShutdown(Left(StartFailed(instance.name.unwrap, "aborted due to shutdown")))
}
}
config.parameters.timeouts.processing.unbounded.await("reconnect-particiapnts")(
MonadUtil
.parTraverseWithLimit_(config.parameters.getStartupParallelism(numThreads))(
(participants.running ++ participantsX.running)
)(reconnect)
.value
)
}
/** Return current time of environment
*/
def now: CantonTimestamp = clock.now
private def allNodesWithGroup = {
allNodes.flatMap { nodeGroup =>
nodeGroup.names().map(name => (name, nodeGroup))
}
}
/** Start all instances described in the configuration
*/
def startAll()(implicit traceContext: TraceContext): Either[StartupError, Unit] =
startNodes(allNodesWithGroup)
def stopAll()(implicit traceContext: TraceContext): Either[ShutdownError, Unit] =
stopNodes(allNodesWithGroup)
def startNodes(
nodes: Seq[(String, Nodes[CantonNode, CantonNodeBootstrap[CantonNode]])]
)(implicit traceContext: TraceContext): Either[StartupError, Unit] = {
runOnNodesOrderedByStartupGroup(
"startup-of-all-nodes",
nodes,
{ case (name, nodes) => nodes.start(name) },
reverse = false,
)
}
def stopNodes(
nodes: Seq[(String, Nodes[CantonNode, CantonNodeBootstrap[CantonNode]])]
)(implicit traceContext: TraceContext): Either[ShutdownError, Unit] = {
runOnNodesOrderedByStartupGroup(
"stop-of-all-nodes",
nodes,
{ case (name, nodes) => nodes.stop(name) },
reverse = true,
)
}
/** run some task on nodes ordered by their startup group
*
* @param reverse if true, then the order will be reverted (e.g. for stop)
*/
private def runOnNodesOrderedByStartupGroup[T, I](
name: String,
nodes: Seq[(String, Nodes[CantonNode, CantonNodeBootstrap[CantonNode]])],
task: (String, Nodes[CantonNode, CantonNodeBootstrap[CantonNode]]) => EitherT[Future, T, I],
reverse: Boolean,
)(implicit traceContext: TraceContext): Either[T, Unit] = {
config.parameters.timeouts.processing.unbounded.await(name)(
MonadUtil
.sequentialTraverse_(
nodes
// parallelize startup by groups (mediator / topology manager need the sequencer to run when we startup)
// as otherwise, they start to emit a few warnings which are ugly
.groupBy { case (_, group) => group.startUpGroup }
.toList
.sortBy { case (group, _) => if (reverse) -group else group }
) { case (_, namesWithGroup) =>
MonadUtil
.parTraverseWithLimit_(config.parameters.getStartupParallelism(numThreads))(
namesWithGroup.sortBy { case (name, _) =>
name // sort by name to make the invocation order deterministic, hence also the result
}
) { case (name, nodes) => task(name, nodes) }
}
.value
)
}
@VisibleForTesting
protected def createParticipant(
name: String,
participantConfig: Config#ParticipantConfigType,
): ParticipantNodeBootstrap = {
participantNodeFactory
.create(
NodeFactoryArguments(
name,
participantConfig,
config.participantNodeParametersByString(name),
createClock(Some(ParticipantNodeBootstrap.LoggerFactoryKeyName -> name)),
metricsFactory.forParticipant(name),
testingConfig,
futureSupervisor,
loggerFactory.append(ParticipantNodeBootstrap.LoggerFactoryKeyName, name),
writeHealthDumpToFile,
configuredOpenTelemetry,
),
testingTimeService,
)
.valueOr(err => throw new RuntimeException(s"Failed to create participant bootstrap: $err"))
}
protected def createParticipantX(
name: String,
participantConfig: Config#ParticipantConfigType,
): ParticipantNodeBootstrapX = {
participantNodeFactoryX
.create(
NodeFactoryArguments(
name,
participantConfig,
// this is okay for x-nodes, as we've merged the two parameter sequences
config.participantNodeParametersByString(name),
createClock(Some(ParticipantNodeBootstrap.LoggerFactoryKeyName -> name)),
metricsFactory.forParticipant(name),
testingConfig,
futureSupervisor,
loggerFactory.append(ParticipantNodeBootstrap.LoggerFactoryKeyName, name),
writeHealthDumpToFile,
configuredOpenTelemetry,
),
testingTimeService,
)
.valueOr(err => throw new RuntimeException(s"Failed to create participant bootstrap: $err"))
}
@VisibleForTesting
protected def createDomain(
name: String,
domainConfig: config.DomainConfigType,
): DomainNodeBootstrap =
domainFactory
.create(
NodeFactoryArguments(
name,
domainConfig,
config.domainNodeParametersByString(name),
createClock(Some(DomainNodeBootstrap.LoggerFactoryKeyName -> name)),
metricsFactory.forDomain(name),
testingConfig,
futureSupervisor,
loggerFactory.append(DomainNodeBootstrap.LoggerFactoryKeyName, name),
writeHealthDumpToFile,
configuredOpenTelemetry,
)
)
.valueOr(err => throw new RuntimeException(s"Failed to create domain bootstrap: $err"))
private def simClocks: Seq[SimClock] = {
val clocks = clock +: (participants.running.map(_.clock) ++ domains.running.map(_.clock))
val simclocks = clocks.collect { case sc: SimClock => sc }
if (simclocks.sizeCompare(clocks) < 0)
logger.warn(s"Found non-sim clocks, testing time service will be broken.")
simclocks
}
def addUserCloseable(closeable: AutoCloseable): Unit = userCloseables.append(closeable)
override def close(): Unit = blocking(this.synchronized {
val closeActorSystem: AutoCloseable =
Lifecycle.toCloseableActorSystem(actorSystem, logger, timeouts)
val closeExecutionContext: AutoCloseable =
ExecutorServiceExtensions(executionContext)(logger, timeouts)
val closeScheduler: AutoCloseable = ExecutorServiceExtensions(scheduler)(logger, timeouts)
val closeHealthServer: AutoCloseable = () => healthServer.foreach(_.close())
val closeHeadlessHealthAdministration: AutoCloseable =
() => healthDumpGenerator.get.foreach(_.grpcAdminCommandRunner.close())
// the allNodes list is ordered in ideal startup order, so reverse to shutdown
val instances =
monitorO.toList ++ userCloseables ++ allNodes.reverse :+ metricsFactory :+ configuredOpenTelemetry :+ clock :+ closeHealthServer :+
closeHeadlessHealthAdministration :+ executionSequencerFactory :+ closeActorSystem :+ closeExecutionContext :+
closeScheduler
logger.info("Closing environment...")
Lifecycle.close((instances.toSeq): _*)(logger)
})
}
object Environment {
/** Ensure all java.util.logging statements are routed to slf4j instead and can be configured with logback.
* This should be paired with adding a LevelChangePropagator to the logback configuration to avoid the performance impact
* of translating all JUL log statements (regardless of whether they are being used).
* See for more details: https://logback.qos.ch/manual/configuration.html#LevelChangePropagator
*/
def installJavaUtilLoggingBridge(): Unit = {
if (!SLF4JBridgeHandler.isInstalled) {
// we want everything going to slf4j so remove any default loggers
SLF4JBridgeHandler.removeHandlersForRootLogger()
SLF4JBridgeHandler.install()
}
}
}
trait EnvironmentFactory[E <: Environment] {
def create(
config: E#Config,
loggerFactory: NamedLoggerFactory,
testingConfigInternal: TestingConfigInternal = TestingConfigInternal(),
): E
}

View File

@ -0,0 +1,72 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.environment
import com.digitalasset.canton.resource.DbMigrations
sealed trait StartupError extends Product with Serializable {
/** Node name */
val name: String
def message: String
override def toString: String = s"${name}: $message"
}
/** The current action cannot be performed when the instance for the given name is running.
*/
final case class AlreadyRunning(name: String) extends StartupError {
def message = s"node is already running: $name"
}
final case class FailedDatabaseMigration(name: String, cause: DbMigrations.Error)
extends StartupError {
def message: String = s"failed to migrate database of $name: $cause"
}
final case class FailedDatabaseVersionChecks(name: String, cause: DbMigrations.DatabaseVersionError)
extends StartupError {
def message: String = s"version checks failed for database of $name: $cause"
}
final case class FailedDatabaseConfigChecks(name: String, cause: DbMigrations.DatabaseConfigError)
extends StartupError {
def message: String = s"config checks failed for database of $name: $cause"
}
final case class FailedDatabaseRepairMigration(name: String, cause: DbMigrations.Error)
extends StartupError {
def message: String = s"failed to repair the database migration of $name: $cause"
}
final case class DidntUseForceOnRepairMigration(name: String) extends StartupError {
def message: String =
s"repair_migration` is a command that may lead to data corruption in the worst case if an " +
s"incompatible database migration is subsequently applied. To use it you need to call `$name.db.repair_migration(force=true)`. " +
s"See `help($name.db.repair_migration)` for more details. "
}
final case class StartFailed(name: String, message: String) extends StartupError
final case class ShutdownDuringStartup(name: String, message: String) extends StartupError
/** Trying to start the node when the database has pending migrations
*/
final case class PendingDatabaseMigration(name: String, pendingMigrationMessage: String)
extends StartupError {
def message = s"failed to initialize $name: $pendingMigrationMessage"
}
sealed trait ShutdownError {
val name: String
def message: String
override def toString: String = s"${name}: $message"
}
/** Configuration for the given name was not found in the CantonConfig
*/
final case class ConfigurationNotFound(name: String) extends StartupError with ShutdownError {
def message = s"configuration not found: $name"
}

View File

@ -0,0 +1,440 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.environment
import cats.data.EitherT
import cats.instances.future.*
import cats.syntax.either.*
import cats.syntax.foldable.*
import cats.{Applicative, Id}
import com.digitalasset.canton.DiscardOps
import com.digitalasset.canton.concurrent.ExecutionContextIdlenessExecutorService
import com.digitalasset.canton.config.{DbConfig, LocalNodeConfig, ProcessingTimeout, StorageConfig}
import com.digitalasset.canton.domain.config.DomainConfig
import com.digitalasset.canton.domain.{Domain, DomainNodeBootstrap, DomainNodeParameters}
import com.digitalasset.canton.lifecycle.*
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.participant.*
import com.digitalasset.canton.participant.config.LocalParticipantConfig
import com.digitalasset.canton.participant.ledger.api.CantonLedgerApiServerWrapper
import com.digitalasset.canton.participant.ledger.api.CantonLedgerApiServerWrapper.MigrateSchemaConfig
import com.digitalasset.canton.resource.DbStorage.RetryConfig
import com.digitalasset.canton.resource.{DbMigrations, DbMigrationsFactory}
import com.digitalasset.canton.tracing.TraceContext
import scala.collection.concurrent.TrieMap
import scala.concurrent.{ExecutionContext, Future, Promise, blocking}
import scala.util.{Failure, Success}
/** Group of CantonNodes of the same type (domains, participants, sequencers). */
trait Nodes[+Node <: CantonNode, +NodeBootstrap <: CantonNodeBootstrap[Node]]
extends FlagCloseable {
type InstanceName = String
/** Returns the startup group (nodes in the same group will start together)
*
* Mediator & Topology manager automatically connect to a domain. Participants
* require an external call to reconnectDomains. Therefore, we can start participants, sequencer and domain
* nodes together, but we have to wait for the sequencers to be up before we can kick off mediators & topology managers.
*/
def startUpGroup: Int
/** Returns the names of all known nodes */
def names(): Seq[InstanceName]
/** Start an individual node by name */
def start(name: InstanceName)(implicit
traceContext: TraceContext
): EitherT[Future, StartupError, Unit]
def startAndWait(name: InstanceName)(implicit
traceContext: TraceContext
): Either[StartupError, Unit]
/** Is the named node running? */
def isRunning(name: InstanceName): Boolean
/** Get the single running node */
def getRunning(name: InstanceName): Option[NodeBootstrap]
/** Get the node while it is still being started. This is mostly useful during testing to access the node in earlier
* stages of its initialization phase.
*/
def getStarting(name: InstanceName): Option[NodeBootstrap]
/** Stop the named node */
def stop(name: InstanceName)(implicit
traceContext: TraceContext
): EitherT[Future, ShutdownError, Unit]
def stopAndWait(name: InstanceName)(implicit
traceContext: TraceContext
): Either[ShutdownError, Unit]
/** Get nodes that are currently running */
def running: Seq[NodeBootstrap]
/** Independently run any pending database migrations for the named node */
def migrateDatabase(name: InstanceName): Either[StartupError, Unit]
/** Independently repair the Flyway schema history table for the named node to reset Flyway migration checksums etc */
def repairDatabaseMigration(name: InstanceName): Either[StartupError, Unit]
}
private sealed trait ManagedNodeStage[T]
private final case class PreparingDatabase[T](
promise: Promise[Either[StartupError, T]]
) extends ManagedNodeStage[T]
private final case class StartingUp[T](
promise: Promise[Either[StartupError, T]],
node: T,
) extends ManagedNodeStage[T]
private final case class Running[T](node: T) extends ManagedNodeStage[T]
/** Nodes group that can start nodes with the provided configuration and factory */
class ManagedNodes[
Node <: CantonNode,
NodeConfig <: LocalNodeConfig,
NodeParameters <: CantonNodeParameters,
NodeBootstrap <: CantonNodeBootstrap[Node],
](
create: (String, NodeConfig) => NodeBootstrap,
migrationsFactory: DbMigrationsFactory,
override protected val timeouts: ProcessingTimeout,
configs: Map[String, NodeConfig],
parametersFor: String => CantonNodeParameters,
override val startUpGroup: Int,
protected val loggerFactory: NamedLoggerFactory,
)(implicit ec: ExecutionContext)
extends Nodes[Node, NodeBootstrap]
with NamedLogging
with HasCloseContext
with FlagCloseableAsync {
private val nodes = TrieMap[InstanceName, ManagedNodeStage[NodeBootstrap]]()
override lazy val names: Seq[InstanceName] = configs.keys.toSeq
override def running: Seq[NodeBootstrap] = nodes.values.toSeq.collect { case Running(node) =>
node
}
def startAndWait(name: InstanceName)(implicit
traceContext: TraceContext
): Either[StartupError, Unit] =
timeouts.unbounded.await(s"Starting node $name")(start(name).value)
override def start(
name: InstanceName
)(implicit
traceContext: TraceContext
): EitherT[Future, StartupError, Unit] =
EitherT
.fromEither[Future](
configs
.get(name)
.toRight(ConfigurationNotFound(name): StartupError)
)
.flatMap(startNode(name, _).map(_ => ()))
private def startNode(
name: InstanceName,
config: NodeConfig,
): EitherT[Future, StartupError, NodeBootstrap] = if (isClosing)
EitherT.leftT(ShutdownDuringStartup(name, "Won't start during shutdown"))
else {
def runStartup(
promise: Promise[Either[StartupError, NodeBootstrap]]
): EitherT[Future, StartupError, NodeBootstrap] = {
val params = parametersFor(name)
val startup = for {
// start migration
_ <- EitherT(Future { checkMigration(name, config.storage, params) })
instance = {
val instance = create(name, config)
nodes.put(name, StartingUp(promise, instance)).discard
instance
}
_ <-
instance.start().leftMap { error =>
instance.close() // clean up resources allocated during instance creation (e.g., db)
StartFailed(name, error): StartupError
}
} yield {
// register the running instance
nodes.put(name, Running(instance)).discard
instance
}
import com.digitalasset.canton.util.Thereafter.syntax.*
promise.completeWith(startup.value)
// remove node upon failure
startup.thereafter {
case Success(Right(_)) => ()
case Success(Left(_)) =>
nodes.remove(name).discard
case Failure(_) =>
nodes.remove(name).discard
}
}
blocking(synchronized {
nodes.get(name) match {
case Some(PreparingDatabase(promise)) => EitherT(promise.future)
case Some(StartingUp(promise, _)) => EitherT(promise.future)
case Some(Running(node)) => EitherT.rightT(node)
case None =>
val promise = Promise[Either[StartupError, NodeBootstrap]]()
nodes
.put(name, PreparingDatabase(promise))
.discard // discard is okay as this is running in the sync block
runStartup(promise) // startup will run async
}
})
}
private def configAndParams(
name: InstanceName
): Either[StartupError, (NodeConfig, CantonNodeParameters)] = {
for {
config <- configs.get(name).toRight(ConfigurationNotFound(name): StartupError)
_ <- checkNotRunning(name)
params = parametersFor(name)
} yield (config, params)
}
override def migrateDatabase(name: InstanceName): Either[StartupError, Unit] = blocking(
synchronized {
for {
cAndP <- configAndParams(name)
(config, params) = cAndP
_ <- runMigration(name, config.storage, params.devVersionSupport)
} yield ()
}
)
override def repairDatabaseMigration(name: InstanceName): Either[StartupError, Unit] = blocking(
synchronized {
for {
cAndP <- configAndParams(name)
(config, params) = cAndP
_ <- runRepairMigration(name, config.storage, params.devVersionSupport)
} yield ()
}
)
override def isRunning(name: InstanceName): Boolean = nodes.contains(name)
override def getRunning(name: InstanceName): Option[NodeBootstrap] = nodes.get(name).collect {
case Running(node) => node
}
override def getStarting(name: InstanceName): Option[NodeBootstrap] = nodes.get(name).collect {
case StartingUp(_, node) => node
}
override def stop(
name: InstanceName
)(implicit
traceContext: TraceContext
): EitherT[Future, ShutdownError, Unit] =
for {
_ <- EitherT.fromEither[Future](
configs.get(name).toRight[ShutdownError](ConfigurationNotFound(name))
)
_ <- nodes.get(name).traverse_(stopStage(name))
} yield ()
override def stopAndWait(name: InstanceName)(implicit
traceContext: TraceContext
): Either[ShutdownError, Unit] =
timeouts.unbounded.await(s"stopping node $name")(stop(name).value)
private def stopStage(name: InstanceName)(
stage: ManagedNodeStage[NodeBootstrap]
)(implicit
traceContext: TraceContext,
ec: ExecutionContext,
): EitherT[Future, ShutdownError, Unit] = {
EitherT(stage match {
// wait for the node to complete startup
case PreparingDatabase(promise) => promise.future
case StartingUp(promise, _) => promise.future
case Running(node) => Future.successful(Right(node))
}).transform {
case Left(_) =>
// we can remap a startup failure to a success here, as we don't want the
// startup failure to propagate into a shutdown failure
Right(())
case Right(node) =>
nodes.remove(name).foreach {
// if there were other processes messing with the node, we won't shutdown
case Running(current) if node == current =>
Lifecycle.close(node)(logger)
case _ =>
logger.info(s"Node $name has already disappeared.")
}
Right(())
}
}
override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = {
val runningInstances = nodes.toList
import TraceContext.Implicits.Empty.*
runningInstances.map { case (name, stage) =>
AsyncCloseable(s"node-$name", stopStage(name)(stage).value, timeouts.closing)
}
}
protected def runIfUsingDatabase[F[_]](storageConfig: StorageConfig)(
fn: DbConfig => F[Either[StartupError, Unit]]
)(implicit F: Applicative[F]): F[Either[StartupError, Unit]] = storageConfig match {
case dbConfig: DbConfig => fn(dbConfig)
case _ => F.pure(Right(()))
}
// if database is fresh, we will migrate it. Otherwise, we will check if there is any pending migrations,
// which need to be triggered manually.
private def checkMigration(
name: InstanceName,
storageConfig: StorageConfig,
params: CantonNodeParameters,
): Either[StartupError, Unit] =
runIfUsingDatabase[Id](storageConfig) { dbConfig =>
val migrations = migrationsFactory.create(dbConfig, name, params.devVersionSupport)
import TraceContext.Implicits.Empty.*
logger.info(s"Setting up database schemas for $name")
def errorMapping(err: DbMigrations.Error): StartupError = {
err match {
case DbMigrations.PendingMigrationError(msg) => PendingDatabaseMigration(name, msg)
case err: DbMigrations.FlywayError => FailedDatabaseMigration(name, err)
case err: DbMigrations.DatabaseError => FailedDatabaseMigration(name, err)
case err: DbMigrations.DatabaseVersionError => FailedDatabaseVersionChecks(name, err)
case err: DbMigrations.DatabaseConfigError => FailedDatabaseConfigChecks(name, err)
}
}
val retryConfig =
if (storageConfig.parameters.failFastOnStartup) RetryConfig.failFast
else RetryConfig.forever
val result = migrations
.checkAndMigrate(params, retryConfig)
.leftMap(errorMapping)
result.value.onShutdown(
Left(ShutdownDuringStartup(name, "DB migration check interrupted due to shutdown"))
)
}
private def checkNotRunning(name: InstanceName): Either[StartupError, Unit] =
if (isRunning(name)) Left(AlreadyRunning(name))
else Right(())
private def runMigration(
name: InstanceName,
storageConfig: StorageConfig,
devVersionSupport: Boolean,
): Either[StartupError, Unit] =
runIfUsingDatabase[Id](storageConfig) { dbConfig =>
migrationsFactory
.create(dbConfig, name, devVersionSupport)
.migrateDatabase()
.leftMap(FailedDatabaseMigration(name, _))
.value
.onShutdown(Left(ShutdownDuringStartup(name, "DB migration interrupted due to shutdown")))
}
private def runRepairMigration(
name: InstanceName,
storageConfig: StorageConfig,
devVersionSupport: Boolean,
): Either[StartupError, Unit] =
runIfUsingDatabase[Id](storageConfig) { dbConfig =>
migrationsFactory
.create(dbConfig, name, devVersionSupport)
.repairFlywayMigration()
.leftMap(FailedDatabaseRepairMigration(name, _))
.value
.onShutdown(
Left(ShutdownDuringStartup(name, "DB repair migration interrupted due to shutdown"))
)
}
}
class ParticipantNodes[B <: CantonNodeBootstrap[N], N <: CantonNode, PC <: LocalParticipantConfig](
create: (String, PC) => B, // (nodeName, config) => bootstrap
migrationsFactory: DbMigrationsFactory,
timeouts: ProcessingTimeout,
configs: Map[String, PC],
parametersFor: String => ParticipantNodeParameters,
loggerFactory: NamedLoggerFactory,
)(implicit
protected val executionContext: ExecutionContextIdlenessExecutorService
) extends ManagedNodes[N, PC, ParticipantNodeParameters, B](
create,
migrationsFactory,
timeouts,
configs,
parametersFor,
startUpGroup = 0,
loggerFactory,
) {
private def migrateIndexerDatabase(name: InstanceName): Either[StartupError, Unit] = {
import TraceContext.Implicits.Empty.*
for {
config <- configs.get(name).toRight(ConfigurationNotFound(name))
parameters = parametersFor(name)
_ = parameters.processingTimeouts.unbounded.await("migrate indexer database") {
runIfUsingDatabase[Future](config.storage) { dbConfig =>
CantonLedgerApiServerWrapper
.migrateSchema(
MigrateSchemaConfig(
dbConfig,
config.ledgerApi.additionalMigrationPaths,
),
loggerFactory,
)
.map(_.asRight)
}
}
} yield ()
}
override def migrateDatabase(name: InstanceName): Either[StartupError, Unit] =
for {
_ <- super.migrateDatabase(name)
_ <- migrateIndexerDatabase(name)
} yield ()
}
object ParticipantNodes {
type ParticipantNodesOld[PC <: LocalParticipantConfig] =
ParticipantNodes[ParticipantNodeBootstrap, ParticipantNode, PC]
type ParticipantNodesX[PC <: LocalParticipantConfig] =
ParticipantNodes[ParticipantNodeBootstrapX, ParticipantNodeX, PC]
}
class DomainNodes[DC <: DomainConfig](
create: (String, DC) => DomainNodeBootstrap,
migrationsFactory: DbMigrationsFactory,
timeouts: ProcessingTimeout,
configs: Map[String, DC],
parameters: String => DomainNodeParameters,
loggerFactory: NamedLoggerFactory,
)(implicit
protected val executionContext: ExecutionContextIdlenessExecutorService
) extends ManagedNodes[Domain, DC, DomainNodeParameters, DomainNodeBootstrap](
create,
migrationsFactory,
timeouts,
configs,
parameters,
startUpGroup = 0,
loggerFactory,
)

View File

@ -0,0 +1,232 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.health
import com.digitalasset.canton.config.{CheckConfig, ProcessingTimeout}
import com.digitalasset.canton.environment.Environment
import com.digitalasset.canton.lifecycle.Lifecycle
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.metrics.HealthMetrics
import com.digitalasset.canton.participant.ParticipantNode
import com.digitalasset.canton.participant.admin.PingService
import com.digitalasset.canton.time.{Clock, WallClock}
import com.digitalasset.canton.topology.UniqueIdentifier
import com.digitalasset.canton.tracing.TraceContext
import com.digitalasset.canton.util.EitherUtil
import org.apache.pekko.actor.ActorSystem
import java.time.{Duration, Instant}
import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.NonFatal
/** Check to determine a health check response */
trait HealthCheck extends AutoCloseable {
/** Ask the check to decide whether we're healthy.
* The future should complete successfully with a HealthCheckResult.
* If the future fails this implies there was an error performing the check itself.
*/
def isHealthy(implicit traceContext: TraceContext): Future[HealthCheckResult]
override def close(): Unit = ()
}
/** Constant response for a health check (used by the always-healthy configuration) */
final case class StaticHealthCheck(private val result: HealthCheckResult) extends HealthCheck {
override def isHealthy(implicit traceContext: TraceContext): Future[HealthCheckResult] =
Future.successful(result)
}
/** Pings the supplied participant to determine health.
* Will be considered unhealthy if unable to resolve the ping service for the participant alias (likely due to startup and initialization).
* Ping success considered healthy, ping failure considered unhealthy.
* If the ping future fails (rather than completing successfully with a failure), it will be converted to a unhealthy response and the exception will be logged at DEBUG level.
*/
class PingHealthCheck(
environment: Environment,
participantAlias: String,
timeout: FiniteDuration,
metrics: HealthMetrics,
protected val loggerFactory: NamedLoggerFactory,
)(implicit executionContext: ExecutionContext)
extends HealthCheck
with NamedLogging {
private val pingLatencyTimer = metrics.pingLatency
override def isHealthy(implicit traceContext: TraceContext): Future[HealthCheckResult] =
getParticipant match {
case Left(failed) => Future.successful(failed)
case Right(participant) =>
val partyId = participant.id.uid
val pingService = participant.ledgerApiDependentCantonServices.adminWorkflowServices.ping
ping(pingService, partyId)
}
private def getParticipant: Either[HealthCheckResult, ParticipantNode] =
for {
init <- Option(
environment.participants
) // if this check is called before the collection has been initialized it will be null, so be very defensive
.flatMap(_.getRunning(participantAlias))
.toRight(Unhealthy("participant is not started"))
participant <- init.getNode.toRight(Unhealthy("participant has not been initialized"))
_ <- Either.cond(
participant.readyDomains.exists(_._2),
(),
Unhealthy("participant is not connected to any domains"),
)
} yield participant
private def ping(pingService: PingService, partyId: UniqueIdentifier)(implicit
traceContext: TraceContext
): Future[HealthCheckResult] = {
val timer = pingLatencyTimer.time()
val started = Instant.now()
val pingResult = for {
result <- pingService.ping(Set(partyId.toProtoPrimitive), Set(), timeout.toMillis)
} yield {
timer.stop()
result match {
case PingService.Success(roundTripTime, _) =>
logger.debug(s"Health check ping completed in ${roundTripTime}")
Healthy
case PingService.Failure =>
val elapsedTime = Duration.between(started, Instant.now)
logger.warn(s"Health check ping failed (elapsed time ${elapsedTime.toMillis}ms)")
Unhealthy("ping failure")
}
}
pingResult recover { case NonFatal(ex) =>
logger.warn("health check ping failed", ex)
Unhealthy("ping failed")
}
}
}
/** For components that simply flag whether they are active or not, just return that.
* @param isActive should return a Right if the instance is active,
* should return Left with a message to be returned on the health endpoint if not.
*/
class IsActiveCheck(
isActive: () => Either[String, Unit],
protected val loggerFactory: NamedLoggerFactory,
) extends HealthCheck {
override def isHealthy(implicit traceContext: TraceContext): Future[HealthCheckResult] =
Future.successful {
isActive().fold(Unhealthy, _ => Healthy)
}
}
/** Rather than executing a check for every isHealthy call periodically run the check and cache the result, and return this cached value for isHealthy.
*/
class PeriodicCheck(
clock: Clock,
interval: FiniteDuration,
protected val loggerFactory: NamedLoggerFactory,
)(check: HealthCheck)(implicit executionContext: ExecutionContext)
extends HealthCheck
with NamedLogging {
/** Once closed we should prevent checks from being run and scheduled */
private val closed = new AtomicBoolean(false)
/** Cached value that will hold the previous health check result.
* It is initialized by calling setupNextCheck so until the first result completes it will hold a pending future.
*/
private val lastCheck = new AtomicReference[Future[HealthCheckResult]](setupFirstCheck)
/** Returns the health check promise rather than updating lastCheck, so is suitable for initializing lastCheck */
private def setupFirstCheck: Future[HealthCheckResult] = {
val isHealthy = TraceContext.withNewTraceContext(check.isHealthy(_))
isHealthy onComplete { _ =>
setupNextCheck()
}
isHealthy
}
/** Runs the check and when completed updates the value of lastCheck */
private def runCheck(): Unit = if (!closed.get()) {
val isHealthy = TraceContext.withNewTraceContext(check.isHealthy(_))
isHealthy onComplete { _ =>
lastCheck.set(isHealthy)
setupNextCheck()
}
}
private def setupNextCheck(): Unit =
if (!closed.get()) {
val _ = clock.scheduleAfter(_ => runCheck(), Duration.ofMillis(interval.toMillis))
}
override def close(): Unit = {
closed.set(true)
Lifecycle.close(clock)(logger)
}
override def isHealthy(implicit traceContext: TraceContext): Future[HealthCheckResult] =
lastCheck.get()
}
object HealthCheck {
def apply(
config: CheckConfig,
metrics: HealthMetrics,
timeouts: ProcessingTimeout,
loggerFactory: NamedLoggerFactory,
)(environment: Environment)(implicit system: ActorSystem): HealthCheck = {
implicit val executionContext = system.dispatcher
config match {
case CheckConfig.AlwaysHealthy =>
StaticHealthCheck(Healthy)
case CheckConfig.Ping(participantAlias, interval, timeout) =>
// only ping periodically rather than on every health check
new PeriodicCheck(
new WallClock(timeouts, loggerFactory.appendUnnamedKey("clock", "ping-health-check")),
interval.underlying,
loggerFactory,
)(
new PingHealthCheck(
environment,
participantAlias,
timeout.underlying,
metrics,
loggerFactory,
)
)
case CheckConfig.IsActive(participantO) =>
val configuredParticipants = environment.config.participantsByString
val participantName = participantO match {
case Some(configName) =>
if (configuredParticipants.contains(configName)) configName
else sys.error(s"Participant with name '$configName' is not configured")
case None =>
configuredParticipants.headOption
.map(_._1)
.getOrElse(
s"IsActive health check must be configured with the participant name to check as there are many participants configured for this environment"
)
}
def isActive: Either[String, Unit] =
for {
participant <- environment.participants
.getRunning(participantName)
.toRight("Participant is not running")
runningParticipant <- participant.getNode.toRight("Participant is not initialized")
_ <- EitherUtil.condUnitE(
runningParticipant.sync.isActive(),
"Participant is not active",
)
} yield ()
new IsActiveCheck(() => isActive, loggerFactory)
}
}
}

View File

@ -0,0 +1,15 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.health
/** Result of a health check */
sealed trait HealthCheckResult
/** Everything that the check checks is healthy */
object Healthy extends HealthCheckResult
/** The check deems something unhealthy
* @param message User printable message describing why a unhealthy result was given
*/
final case class Unhealthy(message: String) extends HealthCheckResult

View File

@ -0,0 +1,87 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.health
import com.digitalasset.canton.config.RequireTypes.Port
import com.digitalasset.canton.config.{HealthConfig, ProcessingTimeout}
import com.digitalasset.canton.environment.Environment
import com.digitalasset.canton.lifecycle.*
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.metrics.HealthMetrics
import com.digitalasset.canton.tracing.TraceContext
import com.google.common.annotations.VisibleForTesting
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.http.scaladsl.marshalling.{Marshaller, ToResponseMarshaller}
import org.apache.pekko.http.scaladsl.model.{HttpEntity, HttpResponse, StatusCodes}
import org.apache.pekko.http.scaladsl.server.Directives.*
import org.apache.pekko.http.scaladsl.server.Route
import org.apache.pekko.http.scaladsl.server.directives.DebuggingDirectives
class HealthServer(
check: HealthCheck,
address: String,
port: Port,
protected override val timeouts: ProcessingTimeout,
protected val loggerFactory: NamedLoggerFactory,
)(implicit system: ActorSystem)
extends FlagCloseableAsync
with NamedLogging {
private val binding = {
import TraceContext.Implicits.Empty.*
timeouts.unbounded.await(s"Binding the health server")(
Http().newServerAt(address, port.unwrap).bind(HealthServer.route(check))
)
}
override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = {
import TraceContext.Implicits.Empty.*
List[AsyncOrSyncCloseable](
AsyncCloseable("binding", binding.unbind(), timeouts.shutdownNetwork),
SyncCloseable("check", Lifecycle.close(check)(logger)),
)
}
}
object HealthServer {
def apply(
config: HealthConfig,
metrics: HealthMetrics,
timeouts: ProcessingTimeout,
loggerFactory: NamedLoggerFactory,
)(environment: Environment)(implicit system: ActorSystem): HealthServer = {
val check = HealthCheck(config.check, metrics, timeouts, loggerFactory)(environment)
new HealthServer(check, config.server.address, config.server.port, timeouts, loggerFactory)
}
/** Routes for powering the health server.
* Provides:
* GET /health => calls check and returns:
* 200 if healthy
* 500 if unhealthy
* 500 if the check fails
*/
@VisibleForTesting
private[health] def route(check: HealthCheck): Route = {
implicit val _marshaller: ToResponseMarshaller[HealthCheckResult] =
Marshaller.opaque {
case Healthy =>
HttpResponse(status = StatusCodes.OK, entity = HttpEntity("healthy"))
case Unhealthy(message) =>
HttpResponse(status = StatusCodes.InternalServerError, entity = HttpEntity(message))
}
get {
path("health") {
DebuggingDirectives.logRequest("health-request") {
DebuggingDirectives.logRequestResult("health-request-response") {
complete(TraceContext.withNewTraceContext(check.isHealthy(_)))
}
}
}
}
}
}

View File

@ -0,0 +1,382 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.metrics
import com.codahale.metrics
import com.codahale.metrics.{Metric, MetricFilter, MetricRegistry}
import com.daml.metrics.api.{MetricName, MetricsContext}
import com.daml.metrics.grpc.DamlGrpcServerMetrics
import com.daml.metrics.{
ExecutorServiceMetrics,
HealthMetrics as DMHealth,
HistogramDefinition,
JvmMetricSet,
}
import com.digitalasset.canton.DomainAlias
import com.digitalasset.canton.buildinfo.BuildInfo
import com.digitalasset.canton.config.DeprecatedConfigUtils.DeprecatedFieldsFor
import com.digitalasset.canton.config.{DeprecatedConfigUtils, NonNegativeFiniteDuration}
import com.digitalasset.canton.domain.metrics.{
DomainMetrics,
EnvMetrics,
MediatorNodeMetrics,
SequencerMetrics,
}
import com.digitalasset.canton.metrics.MetricHandle.{
CantonDropwizardMetricsFactory,
CantonOpenTelemetryMetricsFactory,
}
import com.digitalasset.canton.metrics.MetricsConfig.MetricsFilterConfig
import com.digitalasset.canton.participant.metrics.ParticipantMetrics
import com.typesafe.scalalogging.LazyLogging
import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.metrics.Meter
import io.prometheus.client.dropwizard.DropwizardExports
import java.io.File
import java.util.Locale
import java.util.concurrent.TimeUnit
import scala.annotation.nowarn
import scala.collection.concurrent.TrieMap
final case class MetricsConfig(
reporters: Seq[MetricsReporterConfig] = Seq.empty,
reportJvmMetrics: Boolean = false,
reportExecutionContextMetrics: Boolean = false,
histograms: Seq[HistogramDefinition] = Seq.empty,
)
object MetricsReporterConfig {
object DeprecatedImplicits {
implicit def deprecatedDomainBaseConfig[X <: MetricsReporterConfig]: DeprecatedFieldsFor[X] =
new DeprecatedFieldsFor[MetricsReporterConfig] {
override def deprecatePath: List[DeprecatedConfigUtils.DeprecatedConfigPath[?]] = List(
DeprecatedConfigUtils.DeprecatedConfigPath[String]("type", since = "2.6.0", Some("jmx")),
DeprecatedConfigUtils.DeprecatedConfigPath[String]("type", since = "2.6.0", Some("csv")),
DeprecatedConfigUtils
.DeprecatedConfigPath[String]("type", since = "2.6.0", Some("graphite")),
DeprecatedConfigUtils.DeprecatedConfigPath[String]("filters", since = "2.6.0"),
)
}
}
}
sealed trait MetricsReporterConfig {
def filters: Seq[MetricsFilterConfig]
def metricFilter: MetricFilter =
(name: String, _: Metric) => filters.isEmpty || filters.exists(_.matches(name))
}
sealed trait MetricsPrefix
object MetricsPrefix {
/** Do not use a prefix */
object NoPrefix extends MetricsPrefix
/** Use a static text string as prefix */
final case class Static(prefix: String) extends MetricsPrefix
/** Uses the hostname as the prefix */
object Hostname extends MetricsPrefix
def prefixFromConfig(prefix: MetricsPrefix): Option[String] = prefix match {
case Hostname => Some(java.net.InetAddress.getLocalHost.getHostName)
case NoPrefix => None
case Static(prefix) => Some(prefix)
}
}
object MetricsConfig {
final case class JMX(filters: Seq[MetricsFilterConfig] = Seq.empty) extends MetricsReporterConfig
final case class Csv(
directory: File,
interval: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(5),
filters: Seq[MetricsFilterConfig] = Seq.empty,
) extends MetricsReporterConfig
final case class Graphite(
address: String = "localhost",
port: Int = 2003,
prefix: MetricsPrefix = MetricsPrefix.Hostname,
interval: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(30),
filters: Seq[MetricsFilterConfig] = Seq.empty,
) extends MetricsReporterConfig
final case class Prometheus(address: String = "localhost", port: Int = 9100)
extends MetricsReporterConfig {
override def filters: Seq[MetricsFilterConfig] = Seq.empty
}
final case class MetricsFilterConfig(
startsWith: String = "",
contains: String = "",
endsWith: String = "",
) {
def matches(name: String): Boolean =
name.startsWith(startsWith) && name.contains(contains) && name.endsWith(endsWith)
}
}
final case class MetricsFactory(
reporters: Seq[metrics.Reporter],
registry: metrics.MetricRegistry,
reportJVMMetrics: Boolean,
meter: Meter,
factoryType: MetricsFactoryType,
reportExecutionContextMetrics: Boolean,
) extends AutoCloseable {
@deprecated("Use LabeledMetricsFactory", since = "2.7.0")
val metricsFactory: MetricHandle.MetricsFactory =
createUnlabeledMetricsFactory(MetricsContext.Empty, registry)
@nowarn("cat=deprecation") private val envMetrics = new EnvMetrics(metricsFactory)
private val participants = TrieMap[String, ParticipantMetrics]()
private val domains = TrieMap[String, DomainMetrics]()
private val sequencers = TrieMap[String, SequencerMetrics]()
private val mediators = TrieMap[String, MediatorNodeMetrics]()
private val allNodeMetrics: Seq[TrieMap[String, ?]] =
Seq(participants, domains, sequencers, mediators)
private def nodeMetricsExcept(toExclude: TrieMap[String, ?]): Seq[TrieMap[String, ?]] =
allNodeMetrics filterNot (_ eq toExclude)
val executionServiceMetrics: ExecutorServiceMetrics = new ExecutorServiceMetrics(
createLabeledMetricsFactory(MetricsContext.Empty)
)
object benchmark extends MetricsGroup(MetricName(MetricsFactory.prefix :+ "benchmark"), registry)
object health extends HealthMetrics(MetricName(MetricsFactory.prefix :+ "health"), registry)
// add default, system wide metrics to the metrics reporter
if (reportJVMMetrics) {
registry.registerAll(new JvmMetricSet) // register Daml repo JvmMetricSet
JvmMetricSet.registerObservers() // requires OpenTelemetry to have the global lib setup
}
private def newRegistry(prefix: String): metrics.MetricRegistry = {
val nested = new metrics.MetricRegistry()
registry.register(prefix, nested)
nested
}
def forParticipant(name: String): ParticipantMetrics = {
participants.getOrElseUpdate(
name, {
val metricName = deduplicateName(name, "participant", participants)
val participantMetricsContext =
MetricsContext("participant" -> name, "component" -> "participant")
val participantRegistry = newRegistry(metricName)
new ParticipantMetrics(
name,
MetricsFactory.prefix,
createUnlabeledMetricsFactory(participantMetricsContext, participantRegistry),
createLabeledMetricsFactory(
participantMetricsContext
),
participantRegistry,
reportExecutionContextMetrics,
)
},
)
}
def forEnv: EnvMetrics = envMetrics
def forDomain(name: String): DomainMetrics = {
domains.getOrElseUpdate(
name, {
val metricName = deduplicateName(name, "domain", domains)
val domainMetricsContext = MetricsContext("domain" -> name, "component" -> "domain")
val labeledMetricsFactory =
createLabeledMetricsFactory(domainMetricsContext)
new DomainMetrics(
MetricsFactory.prefix,
createUnlabeledMetricsFactory(domainMetricsContext, newRegistry(metricName)),
new DamlGrpcServerMetrics(labeledMetricsFactory, "domain"),
new DMHealth(labeledMetricsFactory),
)
},
)
}
def forSequencer(name: String): SequencerMetrics = {
sequencers.getOrElseUpdate(
name, {
val metricName = deduplicateName(name, "sequencer", sequencers)
val sequencerMetricsContext =
MetricsContext("sequencer" -> name, "component" -> "sequencer")
val labeledMetricsFactory = createLabeledMetricsFactory(
sequencerMetricsContext
)
new SequencerMetrics(
MetricsFactory.prefix,
createUnlabeledMetricsFactory(sequencerMetricsContext, newRegistry(metricName)),
new DamlGrpcServerMetrics(labeledMetricsFactory, "sequencer"),
new DMHealth(labeledMetricsFactory),
)
},
)
}
def forMediator(name: String): MediatorNodeMetrics = {
mediators.getOrElseUpdate(
name, {
val metricName = deduplicateName(name, "mediator", mediators)
val mediatorMetricsContext = MetricsContext("mediator" -> name, "component" -> "mediator")
val labeledMetricsFactory =
createLabeledMetricsFactory(mediatorMetricsContext)
new MediatorNodeMetrics(
MetricsFactory.prefix,
createUnlabeledMetricsFactory(mediatorMetricsContext, newRegistry(metricName)),
new DamlGrpcServerMetrics(labeledMetricsFactory, "mediator"),
new DMHealth(labeledMetricsFactory),
)
},
)
}
/** de-duplicate name if there is someone using the same name for another type of node (not sure that will ever happen)
*/
private def deduplicateName(
name: String,
nodeType: String,
nodesToExclude: TrieMap[String, ?],
): String =
if (nodeMetricsExcept(nodesToExclude).exists(_.keySet.contains(name)))
s"$nodeType-$name"
else name
private def createLabeledMetricsFactory(extraContext: MetricsContext) = {
factoryType match {
case MetricsFactoryType.InMemory(provider) =>
provider(extraContext)
case MetricsFactoryType.External =>
new CantonOpenTelemetryMetricsFactory(
meter,
globalMetricsContext = MetricsContext(
"canton_version" -> BuildInfo.version
).merge(extraContext),
)
}
}
private def createUnlabeledMetricsFactory(
extraContext: MetricsContext,
registry: MetricRegistry,
) = factoryType match {
case MetricsFactoryType.InMemory(builder) => builder(extraContext)
case MetricsFactoryType.External => new CantonDropwizardMetricsFactory(registry)
}
/** returns the documented metrics by possibly creating fake participants / domains */
def metricsDoc(): (Seq[MetricDoc.Item], Seq[MetricDoc.Item]) = {
def sorted(lst: Seq[MetricDoc.Item]): Seq[MetricDoc.Item] =
lst
.groupBy(_.name)
.flatMap(_._2.headOption.toList)
.toSeq
.sortBy(_.name)
val participantMetrics: ParticipantMetrics =
participants.headOption.map(_._2).getOrElse(forParticipant("dummyParticipant"))
val participantItems = MetricDoc.getItems(participantMetrics)
val clientMetrics =
MetricDoc.getItems(participantMetrics.domainMetrics(DomainAlias.tryCreate("<domain>")))
val domainMetrics = MetricDoc.getItems(
domains.headOption
.map { case (_, domainMetrics) => domainMetrics }
.getOrElse(forDomain("dummyDomain"))
)
// the fake instances are fine here as we do this anyway only when we build and export the docs
(sorted(participantItems ++ clientMetrics), sorted(domainMetrics))
}
override def close(): Unit = reporters.foreach(_.close())
}
object MetricsFactory extends LazyLogging {
import MetricsConfig.*
val prefix: MetricName = MetricName("canton")
def forConfig(
config: MetricsConfig,
openTelemetry: OpenTelemetry,
metricsFactoryType: MetricsFactoryType,
): MetricsFactory = {
val registry = new metrics.MetricRegistry()
val reporter = registerReporter(config, registry)
new MetricsFactory(
reporter,
registry,
config.reportJvmMetrics,
openTelemetry.meterBuilder("canton").build(),
metricsFactoryType,
config.reportExecutionContextMetrics,
)
}
private def registerReporter(
config: MetricsConfig,
registry: metrics.MetricRegistry,
): Seq[metrics.Reporter] = {
config.reporters.map {
case reporterConfig @ JMX(_filters) =>
val reporter =
metrics.jmx.JmxReporter.forRegistry(registry).filter(reporterConfig.metricFilter).build()
logger.debug("Starting metrics reporting using JMX")
reporter.start()
reporter
case reporterConfig @ Csv(directory, interval, _filters) =>
directory.mkdirs()
logger.debug(s"Starting metrics reporting to csv-file ${directory.toString}")
val reporter = metrics.CsvReporter
.forRegistry(registry)
.filter(reporterConfig.metricFilter)
.formatFor(Locale.ENGLISH) // Format decimal numbers like "12345.12345".
.build(directory)
reporter.start(interval.unwrap.toMillis, TimeUnit.MILLISECONDS)
reporter
case reporterConfig @ Graphite(address, port, prefix, interval, _filters) =>
logger.debug(s"Starting metrics reporting for Graphite to $address:$port")
val builder = metrics.graphite.GraphiteReporter
.forRegistry(registry)
.filter(reporterConfig.metricFilter)
val reporter = MetricsPrefix
.prefixFromConfig(prefix)
.fold(builder)(str => builder.prefixedWith(str))
.build(new metrics.graphite.Graphite(address, port))
reporter.start(interval.unwrap.toMillis, TimeUnit.MILLISECONDS)
reporter
// OpenTelemetry registers the prometheus collector during initialization
case Prometheus(hostname, port) =>
logger.debug(s"Exposing metrics for Prometheus on port $hostname:$port")
new DropwizardExports(registry).register[DropwizardExports]()
val reporter = new Reporters.Prometheus(hostname, port)
reporter
}
}
}
class HealthMetrics(prefix: MetricName, registry: metrics.MetricRegistry)
extends MetricsGroup(prefix, registry) {
val pingLatency: metrics.Timer = timer("ping-latency")
}
abstract class MetricsGroup(prefix: MetricName, registry: metrics.MetricRegistry) {
def timer(name: String): metrics.Timer = registry.timer(MetricName(prefix :+ name))
}

View File

@ -0,0 +1,32 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.metrics
import com.codahale.metrics
import io.opentelemetry.sdk.metrics.data.MetricData
import scala.jdk.CollectionConverters.*
final case class MetricsSnapshot(
timers: Map[String, metrics.Timer],
counters: Map[String, metrics.Counter],
gauges: Map[String, metrics.Gauge[_]],
histograms: Map[String, metrics.Histogram],
meters: Map[String, metrics.Meter],
otelMetrics: Seq[MetricData],
)
object MetricsSnapshot {
def apply(registry: metrics.MetricRegistry, reader: OnDemandMetricsReader): MetricsSnapshot = {
MetricsSnapshot(
timers = registry.getTimers.asScala.toMap,
counters = registry.getCounters.asScala.toMap,
gauges = registry.getGauges.asScala.toMap,
histograms = registry.getHistograms.asScala.toMap,
meters = registry.getMeters.asScala.toMap,
otelMetrics = reader.read(),
)
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.metrics
import com.codahale.metrics.Reporter
import io.prometheus.client.exporter.HTTPServer
object Reporters {
class Prometheus(hostname: String, port: Int) extends Reporter {
val server: HTTPServer = new HTTPServer.Builder().withHostname(hostname).withPort(port).build();
override def close(): Unit = server.close()
}
}

View File

@ -0,0 +1 @@
../../../../LICENSE-open-source-bundle.txt

View File

@ -0,0 +1,15 @@
pekko {
loggers = ["org.apache.pekko.event.slf4j.Slf4jLogger"]
loglevel = "INFO"
# the pekko-http server is only used for the health http server within canton.
# It is difficult to configure HAProxy to supply a correct host header when
# we've configured the http-check for the grpc server. So just default to
# assuming requests with no host header are for localhost.
# This should be revisited if we ever expose the server for anything beyond
# health.
http.server.default-host-header = "localhost"
# For canton applications we tear down pekko explicitly.
jvm-shutdown-hooks = off
}

View File

@ -0,0 +1,281 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration [
<!ENTITY entityCorrelationIdTrailingSpaceReplace "&#x0025;replace(tid:&#x0025;mdc{trace-id} ){'tid: ', ''}- &#x0025;msg&#x0025;replace(, context: &#x0025;marker){', context: $', ''}&#x0025;replace( err-context:&#x0025;mdc{err-context} ){' err-context: ', ''}&#x0025;n">
]>
<configuration debug="false">
<!-- propagate logback changes to jul handlers -->
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
<resetJUL>true</resetJUL>
</contextListener>
<!-- whoever figures out how to define the encoder once and doesn't use copy pasta such as I did here wins a price! -->
<if condition='isDefined("LOG_FORMAT_JSON")'>
<then>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="com.digitalasset.canton.logging.CantonJsonEncoder"/>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${LOG_LEVEL_STDOUT:-WARN}</level>
</filter>
</appender>
</then>
<else>
<if condition='isDefined("INTERACTIVE_STDOUT")'>
<then>
<!-- show nice colors and omit date on log output if we are running interactively -->
<!-- attempt to place the correlation-id with a trailing space, however replace with an empty string if it's empty -->
<variable name="pattern" value="%highlight(%-5level %logger{10} &entityCorrelationIdTrailingSpaceReplace;)"/>
<variable name="filter_class" value="com.digitalasset.canton.logging.ThrottleFilterEvaluator"/>
</then>
<else>
<variable name="pattern" value="%date [%thread] %-5level %logger{35} &entityCorrelationIdTrailingSpaceReplace;"/>
<!-- dummy filter which will have no effect (since its level is not set), to reduce code replication -->
<variable name="filter_class" value="ch.qos.logback.classic.filter.ThresholdFilter"/>
</else>
</if>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${LOG_LEVEL_STDOUT:-WARN}</level>
</filter>
<filter class="${filter_class}" />
</appender>
</else>
</if>
<if condition='isDefined("LOG_FILE_FLAT")'>
<if condition='isDefined("LOG_LAST_ERRORS")'>
<then><variable name="log_last_errors_filter" value="com.digitalasset.canton.logging.CantonFilterEvaluator"/></then>
<!-- dummy filter which will have no effect, to reduce code replication -->
<else><variable name="log_last_errors_filter" value="ch.qos.logback.classic.filter.ThresholdFilter"/></else>
</if>
<then>
<if condition='isDefined("LOG_FORMAT_JSON")'>
<then>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_FILE_NAME:-log/canton.log}</file>
<append>${LOG_FILE_APPEND:-true}</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<encoder class="com.digitalasset.canton.logging.CantonJsonEncoder"/>
<filter class="${log_last_errors_filter}" />
</appender>
</then>
<else>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_FILE_NAME:-log/canton.log}</file>
<append>${LOG_FILE_APPEND:-true}</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<encoder>
<!-- attempt to place the correlation-id with a trailing space, however replace with an empty string if it's empty -->
<pattern>%date [%thread] %-5level %logger{10} &entityCorrelationIdTrailingSpaceReplace;</pattern>
</encoder>
<filter class="${log_last_errors_filter}" />
</appender>
</else>
</if>
</then>
<else>
<if condition='isDefined("LOG_FILE_ROLLING")'>
<then>
<if condition='isDefined("LOG_FORMAT_JSON")'>
<then>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_FILE_NAME:-log/canton.log}</file>
<append>true</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- hourly rollover and compress (gz), change pattern if you want different roll-overs -->
<fileNamePattern>${LOG_FILE_NAME:-log/canton.log}.%d{${LOG_FILE_ROLLING_PATTERN:-yyyy-MM-dd}}.gz</fileNamePattern>
<!-- keep max 12 archived log files -->
<maxHistory>${LOG_FILE_HISTORY:-12}</maxHistory>
</rollingPolicy>
<encoder class="com.digitalasset.canton.logging.CantonJsonEncoder"/>
<filter class="${log_last_errors_filter}" />
</appender>
</then>
<else>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_FILE_NAME:-log/canton.log}</file>
<append>true</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- hourly rollover and compress (gz), change pattern if you want different roll-overs -->
<fileNamePattern>${LOG_FILE_NAME:-log/canton.log}.%d{${LOG_FILE_ROLLING_PATTERN:-yyyy-MM-dd}}.gz</fileNamePattern>
<!-- keep max 12 archived log files -->
<maxHistory>${LOG_FILE_HISTORY:-12}</maxHistory>
</rollingPolicy>
<encoder>
<!-- attempt to place the correlation-id with a trailing space, however replace with an empty string if it's empty -->
<pattern>%date [%thread] %-5level %logger{35} &entityCorrelationIdTrailingSpaceReplace;</pattern>
</encoder>
<filter class="${log_last_errors_filter}" />
</appender>
</else>
</if>
</then>
<else>
<appender name="FILE" class="ch.qos.logback.core.helpers.NOPAppender"/>
</else>
</if>
</else>
</if>
<!-- By default, KMS audit logs will go to the Canton log file. To log them to a different file, set the KMS_LOG_FILE_NAME environment variable to the desired file path -->
<if condition='isDefined("KMS_LOG_FILE_NAME")'>
<then>
<if condition='isDefined("LOG_FILE_FLAT")'>
<then>
<if condition='isDefined("LOG_FORMAT_JSON")'>
<then>
<appender name="KMS-FILE" class="ch.qos.logback.core.FileAppender">
<file>${KMS_LOG_FILE_NAME:-log/canton_kms.log}</file>
<append>${KMS_LOG_FILE_APPEND:-true}</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${KMS_LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<encoder class="com.digitalasset.canton.logging.CantonJsonEncoder"/>
</appender>
</then>
<else>
<appender name="KMS-FILE" class="ch.qos.logback.core.FileAppender">
<file>${KMS_LOG_FILE_NAME:-log/canton_kms.log}</file>
<append>${KMS_LOG_FILE_APPEND:-true}</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${KMS_LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<encoder>
<!-- attempt to place the correlation-id with a trailing space, however replace with an empty string if it's empty -->
<pattern>%date [%thread] %-5level %logger{10} &entityCorrelationIdTrailingSpaceReplace;</pattern>
</encoder>
</appender>
</else>
</if>
</then>
<else>
<if condition='isDefined("LOG_FILE_ROLLING")'>
<then>
<if condition='isDefined("LOG_FORMAT_JSON")'>
<then>
<appender name="KMS-FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${KMS_LOG_FILE_NAME:-log/canton_kms.log}</file>
<append>true</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${KMS_LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- hourly rollover and compress (gz), change pattern if you want different roll-overs -->
<fileNamePattern>${KMS_LOG_FILE_NAME:-log/canton_kms.log}.%d{${KMS_LOG_FILE_ROLLING_PATTERN:-yyyy-MM-dd}}.gz</fileNamePattern>
<!-- keep all archived log files by default -->
<maxHistory>${KMS_LOG_FILE_HISTORY:-0}</maxHistory>
</rollingPolicy>
<encoder class="com.digitalasset.canton.logging.CantonJsonEncoder"/>
</appender>
</then>
<else>
<appender name="KMS-FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${KMS_LOG_FILE_NAME:-log/canton_kms.log}</file>
<append>true</append>
<!-- Allow for disabling flush on each log-line (faster, but may miss logs when crashing) -->
<immediateFlush>${KMS_LOG_IMMEDIATE_FLUSH:-true}</immediateFlush>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- hourly rollover and compress (gz), change pattern if you want different roll-overs -->
<fileNamePattern>${KMS_LOG_FILE_NAME:-log/canton_kms.log}.%d{${KMS_LOG_FILE_ROLLING_PATTERN:-yyyy-MM-dd}}.gz</fileNamePattern>
<!-- keep all archived log files by default -->
<maxHistory>${KMS_LOG_FILE_HISTORY:-0}</maxHistory>
</rollingPolicy>
<encoder>
<!-- attempt to place the correlation-id with a trailing space, however replace with an empty string if it's empty -->
<pattern>%date [%thread] %-5level %logger{35} &entityCorrelationIdTrailingSpaceReplace;</pattern>
</encoder>
</appender>
</else>
</if>
</then>
</if>
</else>
</if>
<logger name="com.digitalasset.canton.crypto.kms.aws.audit" level="${LOG_LEVEL_CANTON:-INFO}" additivity="false">
<appender-ref ref="KMS-FILE"/>
</logger>
<logger name="com.digitalasset.canton.crypto.kms.gcp.audit" level="${LOG_LEVEL_CANTON:-INFO}" additivity="false">
<appender-ref ref="KMS-FILE"/>
</logger>
</then>
</if>
<!-- include the rewrite appender to rewrite certain log levels of certain messages -->
<include resource="rewrite-appender.xml"/>
<if condition='"false".equals(p("LOG_IMMEDIATE_FLUSH"))'>
<then>
<include resource="rewrite-async-appender.xml"/>
</then>
</if>
<!-- If log last errors is true, we set the DA loggers to debug log level but filter out at log level canton on the main log files -->
<if condition='isDefined("LOG_LAST_ERRORS")'>
<then>
<logger name="com.digitalasset" level="DEBUG"/>
<logger name="com.daml" level="DEBUG"/>
</then>
<else>
<logger name="com.digitalasset" level="${LOG_LEVEL_CANTON:-INFO}"/>
<logger name="com.daml" level="${LOG_LEVEL_CANTON:-INFO}"/>
</else>
</if>
<if condition='isDefined("LOG_LAST_ERRORS")'>
<then>
<appender name="FILE_LAST_ERRORS" class="ch.qos.logback.core.FileAppender">
<file>${LOG_LAST_ERRORS_FILE_NAME:-log/canton_errors.log}</file>
<append>${LOG_FILE_APPEND:-true}</append>
<encoder>
<!-- attempt to place the correlation-id with a trailing space, however replace with an empty string if it's empty -->
<pattern>%date [%thread] %-5level %logger{10} &entityCorrelationIdTrailingSpaceReplace;</pattern>
</encoder>
</appender>
<if condition='"false".equals(p("LOG_IMMEDIATE_FLUSH"))'>
<then>
<variable name="REWRITE_LOG_LEVEL_MODE" value="REWRITE_LOG_LEVEL"/>
</then>
<else>
<variable name="REWRITE_LOG_LEVEL_MODE" value="REWRITE_LOG_LEVEL_SYNC"/>
</else>
</if>
<!-- Buffer errors for the last_errors command before passing them on to the rewrite appender -->
<appender name="LAST_ERRORS" class="com.digitalasset.canton.logging.LastErrorsAppender">
<appender-ref ref="${REWRITE_LOG_LEVEL_MODE}" />
<lastErrorsFileAppenderName>FILE_LAST_ERRORS</lastErrorsFileAppenderName>
<appender-ref ref="FILE_LAST_ERRORS"/>
</appender>
</then>
</if>
<root level="${LOG_LEVEL_ROOT:-INFO}"></root>
<if condition='isDefined("LOG_LAST_ERRORS")'>
<then>
<root>
<appender-ref ref="LAST_ERRORS" />
</root>
</then>
<else>
<if condition='"false".equals(p("LOG_IMMEDIATE_FLUSH"))'>
<then>
<root>
<appender-ref ref="REWRITE_LOG_LEVEL" />
</root>
</then>
<else>
<root>
<!-- default choice, chosen if LOG_IMMEDIATE_FLUSH is undefined -->
<appender-ref ref="REWRITE_LOG_LEVEL_SYNC" />
</root>
</else>
</if>
</else>
</if>
</configuration>

View File

@ -0,0 +1,10 @@
_____ _
/ ____| | |
| | __ _ _ __ | |_ ___ _ __
| | / _` | '_ \| __/ _ \| '_ \
| |___| (_| | | | | || (_) | | | |
\_____\__,_|_| |_|\__\___/|_| |_|
Welcome to Canton!
Type `help` to get started. `exit` to leave.

View File

@ -0,0 +1,219 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton
import better.files.File
import cats.syntax.either.*
import ch.qos.logback.classic.{Logger, LoggerContext}
import ch.qos.logback.core.status.{ErrorStatus, Status, StatusListener, WarnStatus}
import com.daml.nonempty.NonEmpty
import com.digitalasset.canton.buildinfo.BuildInfo
import com.digitalasset.canton.cli.{Cli, Command, LogFileAppender}
import com.digitalasset.canton.config.ConfigErrors.CantonConfigError
import com.digitalasset.canton.config.{CantonConfig, ConfigErrors, Generate}
import com.digitalasset.canton.environment.{Environment, EnvironmentFactory}
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.tracing.NoTracing
import com.digitalasset.canton.version.ReleaseVersion
import com.typesafe.config.{Config, ConfigFactory}
import org.slf4j.LoggerFactory
import java.util.concurrent.atomic.AtomicReference
import scala.util.control.NonFatal
/** The Canton main application.
*
* Starts a set of domains and participant nodes.
*/
abstract class CantonAppDriver[E <: Environment] extends App with NamedLogging with NoTracing {
protected def environmentFactory: EnvironmentFactory[E]
protected def withManualStart(config: E#Config): E#Config
protected def additionalVersions: Map[String, String] = Map.empty
protected def printVersion(): Unit = {
(Map(
"Canton" -> BuildInfo.version,
"Daml Libraries" -> BuildInfo.damlLibrariesVersion,
"Supported Canton protocol versions" -> BuildInfo.protocolVersions.toString(),
) ++ additionalVersions) foreach { case (name, version) =>
Console.out.println(s"$name: $version")
}
}
// BE CAREFUL: Set the environment variables before you touch anything related to
// logback as otherwise, the logback configuration will be read without these
// properties being considered
private val cliOptions = Cli.parse(args, printVersion()).getOrElse(sys.exit(1))
cliOptions.installLogging()
// Fail, if the log configuration cannot be read.
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
private val loggerContext = LoggerFactory.getILoggerFactory.asInstanceOf[LoggerContext]
private val logbackStatusManager = loggerContext.getStatusManager
private val killingStatusListener: StatusListener = {
case status @ (_: WarnStatus | _: ErrorStatus) =>
Console.err.println(s"Unable to load log configuration.\n$status")
Console.err.flush()
sys.exit(-1)
case _: Status => // ignore
}
logbackStatusManager.add(killingStatusListener)
// Use the root logger as named logger to avoid a prefix "CantonApp" in log files.
override val loggerFactory: NamedLoggerFactory = NamedLoggerFactory.root
// Adjust root and canton loggers which works even if a custom logback.xml is defined
Seq(
(cliOptions.levelCanton, "com.digitalasset"),
(cliOptions.levelCanton, "com.daml"),
(cliOptions.levelRoot, org.slf4j.Logger.ROOT_LOGGER_NAME),
)
.foreach {
case (Some(level), loggerName) =>
@SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf"))
val root: Logger = LoggerFactory.getLogger(loggerName).asInstanceOf[Logger]
root.setLevel(level)
case (None, _) =>
}
logger.info(s"Starting Canton version ${ReleaseVersion.current}")
if (cliOptions.logTruncate) {
cliOptions.logFileAppender match {
case LogFileAppender.Rolling =>
logger.warn(
"Ignoring log file truncation request, as it only works with flat log files, but here we use rolling log files."
)
case LogFileAppender.Flat =>
case LogFileAppender.Off =>
}
}
// Now that at least one line has been logged, deregister the killingStatusManager so that
// Canton does not die on a warning status.
logbackStatusManager.remove(killingStatusListener)
private val environmentRef: AtomicReference[Option[E]] = new AtomicReference(None)
sys.runtime.addShutdownHook(new Thread(() => {
try {
logger.info("Shutting down...")
environmentRef.get().foreach(_.close())
logger.info("Shutdown complete.")
} catch {
case NonFatal(exception) =>
logger.error("Failed to shut down successfully.", exception)
} finally {
LoggerFactory.getILoggerFactory match {
case logbackLoggerContext: LoggerContext =>
logger.info("Shutting down logger. Bye bye.")
logbackLoggerContext.stop()
case _ =>
logger.warn(
"Logback is not bound via slf4j. Cannot shut down logger, this could result in lost log-messages."
)
}
}
}))
logger.debug("Registered shutdown-hook.")
val cantonConfig: E#Config = {
val mergedUserConfigsE = NonEmpty.from(cliOptions.configFiles) match {
case None if cliOptions.configMap.isEmpty =>
Left(ConfigErrors.NoConfigFiles.Error())
case None => Right(ConfigFactory.empty())
case Some(neConfigFiles) => CantonConfig.parseAndMergeJustCLIConfigs(neConfigFiles)
}
val mergedUserConfigs =
mergedUserConfigsE.valueOr { _ =>
sys.exit(1)
}
val configFromMap = {
import scala.jdk.CollectionConverters.*
ConfigFactory.parseMap(cliOptions.configMap.asJava)
}
val finalConfig = CantonConfig.mergeConfigs(mergedUserConfigs, Seq(configFromMap))
val loadedConfig = loadConfig(finalConfig) match {
case Left(_) =>
if (cliOptions.configFiles.sizeCompare(1) > 0)
writeConfigToTmpFile(mergedUserConfigs)
sys.exit(1)
case Right(loaded) =>
if (cliOptions.manualStart) withManualStart(loaded)
else loaded
}
if (loadedConfig.monitoring.logging.logConfigOnStartup) {
// we have two ways to log the config. both have their pro and cons.
// full means we include default values. in such a case, it's hard to figure
// out what really the config settings are.
// the other method just uses the loaded `Config` object that doesn't have default
// values, but therefore needs a separate way to handle the rendering
logger.info(
"Starting up with resolved config:\n" +
(if (loadedConfig.monitoring.logging.logConfigWithDefaults)
loadedConfig.dumpString
else
CantonConfig.renderForLoggingOnStartup(finalConfig))
)
}
loadedConfig
}
private def writeConfigToTmpFile(mergedUserConfigs: Config) = {
val tmp = File.newTemporaryFile("canton-config-error-", ".conf")
logger.error(
s"An error occurred after parsing a config file that was obtained by merging multiple config " +
s"files. The resulting merged-together config file, for which the error occurred, was written to '$tmp'."
)
tmp
.write(
mergedUserConfigs
.root()
.render(CantonConfig.defaultConfigRenderer)
)
.discard
}
// verify that run script and bootstrap script aren't mixed
if (cliOptions.bootstrapScriptPath.isDefined) {
cliOptions.command match {
case Some(Command.RunScript(_)) =>
logger.error("--bootstrap script and run script are mutually exclusive")
sys.exit(1)
case Some(Command.Generate(_)) =>
logger.error("--bootstrap script and generate are mutually exclusive")
sys.exit(1)
case _ =>
}
}
private lazy val bootstrapScript: Option[CantonScript] =
cliOptions.bootstrapScriptPath
.map(CantonScriptFromFile)
val runner: Runner[E] = cliOptions.command match {
case Some(Command.Daemon) => new ServerRunner(bootstrapScript, loggerFactory)
case Some(Command.RunScript(script)) => ConsoleScriptRunner(script, loggerFactory)
case Some(Command.Generate(target)) =>
Generate.process(target, cantonConfig)
sys.exit(0)
case _ =>
new ConsoleInteractiveRunner(cliOptions.noTty, bootstrapScript, loggerFactory)
}
val environment = environmentFactory.create(cantonConfig, loggerFactory)
environmentRef.set(Some(environment)) // registering for graceful shutdown
environment.startAndReconnect(cliOptions.autoConnectLocal) match {
case Right(()) =>
case Left(_) => sys.exit(1)
}
runner.run(environment)
def loadConfig(config: Config): Either[CantonConfigError, E#Config]
}

View File

@ -0,0 +1,25 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton
import com.digitalasset.canton.config.CantonCommunityConfig
import com.digitalasset.canton.config.ConfigErrors.CantonConfigError
import com.digitalasset.canton.environment.{
CommunityEnvironment,
CommunityEnvironmentFactory,
EnvironmentFactory,
}
import com.typesafe.config.Config
object CantonCommunityApp extends CantonAppDriver[CommunityEnvironment] {
override def loadConfig(config: Config): Either[CantonConfigError, CantonCommunityConfig] =
CantonCommunityConfig.load(config)
override protected def environmentFactory: EnvironmentFactory[CommunityEnvironment] =
CommunityEnvironmentFactory
override protected def withManualStart(config: CantonCommunityConfig): CantonCommunityConfig =
config.copy(parameters = config.parameters.copy(manualStart = true))
}

View File

@ -0,0 +1,186 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton
import com.digitalasset.canton.console.{HeadlessConsole, InteractiveConsole}
import com.digitalasset.canton.environment.Environment
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging, TracedLogger}
import com.digitalasset.canton.tracing.{NoTracing, TraceContext}
import java.io.{File, OutputStream, StringWriter}
import scala.io.Source
import scala.util.control.NonFatal
/** Result for exposing the process exit code.
* All logging is expected to take place inside of the runner.
*/
trait Runner[E <: Environment] extends NamedLogging {
def run(environment: E): Unit
}
class ServerRunner[E <: Environment](
bootstrapScript: Option[CantonScript] = None,
override val loggerFactory: NamedLoggerFactory,
) extends Runner[E]
with NoTracing {
def run(environment: E): Unit =
try {
def start(): Unit = {
environment
.startAll() match {
case Right(_) => logger.info("Canton started")
case Left(error) =>
logger.error(s"Canton startup encountered problems: $error")
// give up as we couldn't start everything successfully
sys.exit(1)
}
}
def startWithBootstrap(script: CantonScript): Unit =
ConsoleScriptRunner.run(environment, script, logger = logger) match {
case Right(_unit) => logger.info("Bootstrap script successfully executed.")
case Left(err) =>
logger.error(s"Bootstrap script terminated with an error: $err")
sys.exit(3)
}
bootstrapScript.fold(start())(startWithBootstrap)
} catch {
case ex: Throwable =>
logger.error(s"Unexpected error while running server: ${ex.getMessage}")
logger.info("Exception causing error is:", ex)
sys.exit(2)
}
}
class ConsoleInteractiveRunner[E <: Environment](
noTty: Boolean = false,
bootstrapScript: Option[CantonScript],
override val loggerFactory: NamedLoggerFactory,
) extends Runner[E] {
def run(environment: E): Unit = {
val success =
try {
val consoleEnvironment = environment.createConsole()
InteractiveConsole(consoleEnvironment, noTty, bootstrapScript, logger)
} catch {
case NonFatal(_) => false
}
sys.exit(if (success) 0 else 1)
}
}
class ConsoleScriptRunner[E <: Environment](
scriptPath: CantonScript,
override val loggerFactory: NamedLoggerFactory,
) extends Runner[E] {
private val Ok = 0
private val Error = 1
override def run(environment: E): Unit = {
val exitCode =
ConsoleScriptRunner.run(environment, scriptPath, logger) match {
case Right(_unit) =>
Ok
case Left(err) =>
logger.error(s"Script execution failed: $err")(TraceContext.empty)
Error
}
sys.exit(exitCode)
}
}
private class CopyOutputWriter(parent: OutputStream, logger: TracedLogger)
extends OutputStream
with NoTracing {
val buf = new StringWriter()
override def write(b: Int): Unit = {
if (b == '\n') {
// strip the ansi color commands from the string
val output = buf.toString.replaceAll("\u001B\\[[;\\d]*m", "")
logger.info(s"Console stderr output: ${output}")
buf.getBuffer.setLength(0)
} else {
buf.write(b)
}
parent.write(b)
}
}
sealed trait CantonScript {
def path: Option[File]
def read(): Either[HeadlessConsole.IoError, String]
}
final case class CantonScriptFromFile(scriptPath: File) extends CantonScript {
override val path = Some(scriptPath)
override def read(): Either[HeadlessConsole.IoError, String] =
readScript(scriptPath)
private def readScript(scriptPath: File): Either[HeadlessConsole.IoError, String] =
for {
path <- verifyScriptCanBeRead(scriptPath)
content <- readScriptContent(path)
} yield content
private def verifyScriptCanBeRead(scriptPath: File): Either[HeadlessConsole.IoError, File] =
Either.cond(
scriptPath.canRead,
scriptPath,
HeadlessConsole.IoError(s"Script file not readable: $scriptPath"),
)
private def readScriptContent(scriptPath: File): Either[HeadlessConsole.IoError, String] = {
val source = Source.fromFile(scriptPath)
try {
Right(source.mkString)
} catch {
case NonFatal(ex: Throwable) =>
Left(HeadlessConsole.IoError(s"Failed to read script file: $ex"))
} finally {
source.close()
}
}
}
object ConsoleScriptRunner extends NoTracing {
def apply[E <: Environment](
scriptPath: File,
loggerFactory: NamedLoggerFactory,
): ConsoleScriptRunner[E] =
new ConsoleScriptRunner[E](CantonScriptFromFile(scriptPath), loggerFactory)
def run[E <: Environment](
environment: E,
scriptPath: File,
logger: TracedLogger,
): Either[HeadlessConsole.HeadlessConsoleError, Unit] =
run(environment, CantonScriptFromFile(scriptPath), logger)
def run[E <: Environment](
environment: E,
cantonScript: CantonScript,
logger: TracedLogger,
): Either[HeadlessConsole.HeadlessConsoleError, Unit] = {
val consoleEnvironment = environment.createConsole()
try {
for {
scriptCode <- cantonScript.read()
_ <- HeadlessConsole.run(
consoleEnvironment,
scriptCode,
cantonScript.path,
// clone error stream such that we also log the error message
// unfortunately, this means that if somebody outputs INFO to stdout,
// he will observe the error twice
transformer = x => x.copy(errorStream = new CopyOutputWriter(x.errorStream, logger)),
logger = logger,
)
} yield ()
} finally {
consoleEnvironment.closeChannels()
}
}
}

View File

@ -0,0 +1,387 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.cli
import ch.qos.logback.classic.Level
import com.digitalasset.canton.DiscardOps
import com.digitalasset.canton.buildinfo.BuildInfo
import scopt.OptionParser
import java.io.File
import scala.annotation.nowarn
sealed trait LogFileAppender
object LogFileAppender {
object Rolling extends LogFileAppender
object Flat extends LogFileAppender
object Off extends LogFileAppender
}
sealed trait LogEncoder
object LogEncoder {
object Plain extends LogEncoder
object Json extends LogEncoder
}
/** CLI Options
*
* See the description for each argument in the CLI builder below.
*/
final case class Cli(
configFiles: Seq[File] = Seq(),
configMap: Map[String, String] = Map(),
command: Option[Command] = None,
noTty: Boolean = false,
levelRoot: Option[Level] = None,
levelCanton: Option[Level] = None,
levelStdout: Level = Level.WARN,
logFileAppender: LogFileAppender = LogFileAppender.Rolling,
logFileRollingPattern: Option[String] = None,
kmsLogFileRollingPattern: Option[String] = None,
logFileHistory: Option[Int] = None,
kmsLogFileHistory: Option[Int] = None,
logTruncate: Boolean = false,
logFileName: Option[String] = None,
kmsLogFileName: Option[String] = None,
logEncoder: LogEncoder = LogEncoder.Plain,
logLastErrors: Boolean = true,
logLastErrorsFileName: Option[String] = None,
logImmediateFlush: Option[Boolean] = None,
kmsLogImmediateFlush: Option[Boolean] = None,
bootstrapScriptPath: Option[File] = None,
manualStart: Boolean = false,
autoConnectLocal: Boolean = false,
) {
/** sets the properties our logback.xml is looking for */
def installLogging(): Unit = {
setLevel(levelRoot, "LOG_LEVEL_ROOT")
// The root level can override the canton level if root is configured with a lower level
val overrideLevelCanton = (for {
root <- levelRoot
canton <- levelCanton
} yield root.levelInt < canton.levelInt).getOrElse(false)
if (overrideLevelCanton)
setLevel(levelRoot, "LOG_LEVEL_CANTON")
else
setLevel(levelCanton, "LOG_LEVEL_CANTON")
setLevel(Some(levelStdout), "LOG_LEVEL_STDOUT")
if (command.isEmpty && !noTty) {
// Inform logging that this is an interactive console running that needs some additional tweaks
// for a good logging experience
System.setProperty("INTERACTIVE_STDOUT", true.toString)
}
System.setProperty("LOG_FILE_APPEND", (!logTruncate).toString)
Seq(
"LOG_FILE_FLAT",
"LOG_FILE_ROLLING",
"LOG_FILE_NAME",
"KMS_LOG_FILE_NAME",
"LOG_FILE_ROLLING_PATTERN",
"KMS_LOG_FILE_ROLLING_PATTERN",
"LOG_FILE_HISTORY",
"KMS_LOG_FILE_HISTORY",
"LOG_LAST_ERRORS",
"LOG_LAST_ERRORS_FILE_NAME",
"LOG_FORMAT_JSON",
"LOG_IMMEDIATE_FLUSH",
"KMS_LOG_IMMEDIATE_FLUSH",
).foreach(System.clearProperty(_).discard[String])
logFileName.foreach(System.setProperty("LOG_FILE_NAME", _))
kmsLogFileName.foreach(System.setProperty("KMS_LOG_FILE_NAME", _))
logLastErrorsFileName.foreach(System.setProperty("LOG_LAST_ERRORS_FILE_NAME", _))
logFileHistory.foreach(x => System.setProperty("LOG_FILE_HISTORY", x.toString))
kmsLogFileHistory.foreach(x => System.setProperty("KMS_LOG_FILE_HISTORY", x.toString))
logFileRollingPattern.foreach(System.setProperty("LOG_FILE_ROLLING_PATTERN", _))
kmsLogFileRollingPattern.foreach(System.setProperty("KMS_LOG_FILE_ROLLING_PATTERN", _))
logFileAppender match {
case LogFileAppender.Rolling =>
System.setProperty("LOG_FILE_ROLLING", "true").discard
case LogFileAppender.Flat =>
System.setProperty("LOG_FILE_FLAT", "true").discard
case LogFileAppender.Off =>
}
if (logLastErrors)
System.setProperty("LOG_LAST_ERRORS", "true").discard
logEncoder match {
case LogEncoder.Plain =>
case LogEncoder.Json =>
System.setProperty("LOG_FORMAT_JSON", "true").discard
}
logImmediateFlush.foreach(f => System.setProperty("LOG_IMMEDIATE_FLUSH", f.toString))
kmsLogImmediateFlush.foreach(f => System.setProperty("KMS_LOG_IMMEDIATE_FLUSH", f.toString))
}
private def setLevel(levelO: Option[Level], name: String): Unit = {
val _ = levelO match {
case Some(level) => System.setProperty(name, level.levelStr)
case None => System.clearProperty(name)
}
}
}
@nowarn(raw"msg=unused value of type .* \(add `: Unit` to discard silently\)")
object Cli {
// The `additionalVersions` parameter allows the enterprise CLI to output the version of additional,
// enterprise-only dependencies (see `CantonAppDriver`).
def parse(args: Array[String], printVersion: => Unit = ()): Option[Cli] =
parser(printVersion).parse(args, Cli())
private def parser(printVersion: => Unit): OptionParser[Cli] =
new scopt.OptionParser[Cli]("canton") {
private def inColumns(first: String = "", second: String = ""): String =
f" $first%-25s$second"
head("Canton", s"v${BuildInfo.version}")
help('h', "help").text("Print usage")
opt[Unit]("version")
.text("Print versions")
.action { (_, _) =>
printVersion.discard
sys.exit(0)
}
opt[Seq[File]]('c', "config")
.text(
"Set configuration file(s).\n" +
inColumns(second = "If several configuration files assign values to the same key,\n") +
inColumns(second = "the last value is taken.")
)
.valueName("<file1>,<file2>,...")
.unbounded()
.action((files, cli) => cli.copy(configFiles = cli.configFiles ++ files))
opt[Map[String, String]]('C', "config key-value's")
.text(
"Set configuration key value pairs directly.\n" +
inColumns(second = "Can be useful for providing simple short config info.")
)
.valueName("<key1>=<value1>,<key2>=<value2>")
.unbounded()
.action { (map, cli) =>
cli.copy(configMap =
map ++ cli.configMap
) // the values on the right of the ++ operator are preferred for the same key. thus in case of repeated keys, the first defined is taken.
}
opt[File]("bootstrap")
.text("Set a script to run on startup")
.valueName("<file>")
.action((script, cli) => cli.copy(bootstrapScriptPath = Some(script)))
opt[Unit]("no-tty")
.text("Do not use a tty")
.action((_, cli) => cli.copy(noTty = true))
opt[Unit]("manual-start")
.text("Don't automatically start the nodes")
.action((_, cli) => cli.copy(manualStart = true))
opt[Unit]("auto-connect-local")
.text("Automatically connect all local participants to all local domains")
.action((_, cli) => cli.copy(autoConnectLocal = true))
note(inColumns(first = "-D<property>=<value>", second = "Set a JVM property value"))
note("\nLogging Options:") // Enforce a newline in the help text
opt[Unit]('v', "verbose")
.text("Canton logger level -> DEBUG")
.action((_, cli) => cli.copy(levelCanton = Some(Level.DEBUG)))
opt[Unit]("debug")
.text("Console/stdout level -> INFO, root logger -> DEBUG")
.action((_, cli) =>
cli.copy(
levelRoot = Some(Level.DEBUG),
levelCanton = Some(Level.DEBUG),
levelStdout = Level.INFO,
)
)
opt[Unit]("log-truncate")
.text("Truncate log file on startup.")
.action((_, cli) => cli.copy(logTruncate = true))
implicit val levelRead: scopt.Read[Level] = scopt.Read.reads(Level.valueOf)
opt[Level]("log-level-root")
.text("Log-level of the root logger")
.valueName("<LEVEL>")
.action((level, cli) => cli.copy(levelRoot = Some(level), levelCanton = Some(level)))
opt[Level]("log-level-canton")
.text("Log-level of the Canton logger")
.valueName("<LEVEL>")
.action((level, cli) => cli.copy(levelCanton = Some(level)))
opt[Level]("log-level-stdout")
.text("Log-level of stdout")
.valueName("<LEVEL>")
.action((level, cli) => cli.copy(levelStdout = level))
opt[String]("log-file-appender")
.text("Type of log file appender")
.valueName("rolling(default)|flat|off")
.action((typ, cli) =>
typ.toLowerCase match {
case "rolling" => cli.copy(logFileAppender = LogFileAppender.Rolling)
case "off" => cli.copy(logFileAppender = LogFileAppender.Off)
case "flat" => cli.copy(logFileAppender = LogFileAppender.Flat)
case _ =>
throw new IllegalArgumentException(
s"Invalid command line argument: unknown log-file-appender $typ"
)
}
)
opt[String]("log-file-name")
.text("Name and location of log-file, default is log/canton.log")
.action((name, cli) => cli.copy(logFileName = Some(name)))
opt[String]("kms-log-file-name")
.text("Name and location of KMS log-file, default is log/canton_kms.log")
.action((name, cli) => cli.copy(kmsLogFileName = Some(name)))
opt[Int]("log-file-rolling-history")
.text("Number of history files to keep when using rolling log file appender.")
.action((history, cli) => cli.copy(logFileHistory = Some(history)))
opt[Int]("kms-log-file-rolling-history")
.text("Number of history KMS files to keep when using rolling log file appender.")
.action((history, cli) => cli.copy(kmsLogFileHistory = Some(history)))
opt[String]("log-file-rolling-pattern")
.text("Log file suffix pattern of rolling file appender. Default is 'yyyy-MM-dd'.")
.action((pattern, cli) => cli.copy(logFileRollingPattern = Some(pattern)))
opt[String]("kms-log-file-rolling-pattern")
.text("KMS log file suffix pattern of rolling file appender. Default is 'yyyy-MM-dd'.")
.action((pattern, cli) => cli.copy(kmsLogFileRollingPattern = Some(pattern)))
opt[String]("log-encoder")
.text("Log encoder: plain|json")
.action {
case ("json", cli) => cli.copy(logEncoder = LogEncoder.Json)
case ("plain", cli) => cli.copy(logEncoder = LogEncoder.Plain)
case (other, _) =>
throw new IllegalArgumentException(s"Unsupported logging encoder $other")
}
opt[Boolean]("log-immediate-flush")
.text(
"""Determines whether to immediately flush log output to the log file.
|Enable to avoid an incomplete log file in case of a crash.
|Disable to reduce the load on the disk caused by logging.""".stripMargin
)
.valueName("true(default)|false")
.action((enabled, cli) => cli.copy(logImmediateFlush = Some(enabled)))
opt[Boolean]("kms-log-immediate-flush")
.text(
"""Determines whether to immediately flush KMS log output to the KMS log file.
|Enable to avoid an incomplete log file in case of a crash.
|Disable to reduce the load on the disk caused by logging.""".stripMargin
)
.valueName("true(default)|false")
.action((enabled, cli) => cli.copy(kmsLogImmediateFlush = Some(enabled)))
opt[String]("log-profile")
.text("Preconfigured logging profiles: (container)")
.action((profile, cli) =>
profile.toLowerCase match {
case "container" =>
cli.copy(
logFileAppender = LogFileAppender.Rolling,
logFileHistory = Some(10),
logFileRollingPattern = Some("yyyy-MM-dd-HH"),
levelStdout = Level.DEBUG,
)
case _ => throw new IllegalArgumentException(s"Unknown log profile $profile")
}
)
opt[Boolean]("log-last-errors")
.text("Capture events for logging.last_errors command")
.action((isEnabled, cli) => cli.copy(logLastErrors = isEnabled))
note("") // Enforce a newline in the help text
note("Use the JAVA_OPTS environment variable to set JVM parameters.")
note("") // Enforce a newline in the help text
cmd("daemon")
.text(
"Start all nodes automatically and run them without having a console (REPL).\n" +
"Nodes can be controlled through the admin API."
)
.action((_, cli) => cli.copy(command = Some(Command.Daemon)))
.children()
note("") // Enforce a newline in the help text
cmd("run")
.text(
"Run a console script.\n" +
"Stop all nodes when the script has terminated."
)
.children(
arg[File]("<file>")
.text("the script to run")
.action((script, cli) => cli.copy(command = Some(Command.RunScript(script))))
)
note("") // Enforce a newline in the help text
implicit val readTarget: scopt.Read[Command.Generate.Target] = scopt.Read.reads {
case "remote-config" => Command.Generate.RemoteConfig
case x => throw new IllegalArgumentException(s"Unknown target $x")
}
cmd("generate")
.text("Generate configurations")
.children(
arg[Command.Generate.Target]("<type>")
.text("generation target (remote-config)")
.action((target, cli) => cli.copy(command = Some(Command.Generate(target))))
)
checkConfig(cli =>
if (cli.configFiles.isEmpty && cli.configMap.isEmpty) {
failure(
"at least one config has to be defined either as files (-c) or as key-values (-C)"
)
} else success
)
checkConfig(cli =>
if (
cli.autoConnectLocal && cli.command.exists {
case Command.Daemon => false
case Command.RunScript(_) => true
case Command.Generate(_) => true
}
) {
failure(s"auto-connect-local does not work with run-script or generate")
} else success
)
override def showUsageOnError: Option[Boolean] = Some(true)
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.cli
import java.io.File
sealed trait Command {}
object Command {
/** Run the process as a server (rather than an interactive repl)
*/
object Daemon extends Command
/** Run a console script then close
*
* @param scriptPath the path to the script
*/
final case class RunScript(scriptPath: File) extends Command
final case class Generate(target: Generate.Target) extends Command
object Generate {
sealed trait Target
object RemoteConfig extends Target
}
}

View File

@ -0,0 +1,42 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.config
import better.files.{File as BFile}
import com.digitalasset.canton.cli.Command
import com.digitalasset.canton.environment.Environment
import pureconfig.ConfigWriter
object Generate {
private def write[A](name: String, prefix: String, config: A)(implicit
configWriter: ConfigWriter[A]
): Unit = {
val _ = BFile(s"remote-${name}.conf")
.write(
s"canton.remote-${prefix}.${name} {" + System.lineSeparator() + configWriter
.to(config)
.render(CantonConfig.defaultConfigRenderer) + System.lineSeparator() + "}" + System
.lineSeparator()
)
}
def process[E <: Environment](command: Command.Generate.Target, config: E#Config): Unit =
command match {
case Command.Generate.RemoteConfig =>
val writers = new CantonConfig.ConfigWriters(confidential = false)
import writers.*
config.participantsByString.map(x => (x._1, x._2.toRemoteConfig)).foreach {
case (name, config) =>
write(name, "participants", config)
}
config.domainsByString.map(x => (x._1, x._2.toRemoteConfig)).foreach {
case (name, config) =>
write(name, "domains", config)
}
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import ammonite.interp.api.APIHolder
import ammonite.util.Bind
/** ammonite requires a ApiHolder in this pattern to make items through bindings available within the dynamic Console environment.
*/
final case class BindingsHolder(bindings: IndexedSeq[Bind[_]])
object BindingsBridge extends APIHolder[BindingsHolder]

View File

@ -0,0 +1,259 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import ammonite.Main
import ammonite.interp.Interpreter
import ammonite.runtime.Frame
import ammonite.util.Res.{Exception, Failing, Failure, Success}
import ammonite.util.*
import cats.syntax.either.*
import com.digitalasset.canton.console.HeadlessConsole.{
HeadlessConsoleError,
convertAmmoniteResult,
createInterpreter,
initializePredef,
runCode,
}
import com.digitalasset.canton.logging.TracedLogger
import com.digitalasset.canton.tracing.NoTracing
import com.digitalasset.canton.util.ErrorUtil
import os.PathConvertible.*
import java.io.File
import java.util.concurrent.atomic.{AtomicInteger, AtomicReference}
import scala.util.Try
class HeadlessConsole(
consoleEnvironment: ConsoleEnvironment,
transformer: Main => Main = identity,
logger: TracedLogger,
) extends AutoCloseable {
val (lock, baseOptions) =
AmmoniteConsoleConfig.create(
consoleEnvironment.environment.config.parameters.console,
predefCode = "",
welcomeBanner = None,
isRepl = false,
logger,
)
private val interpreterO = new AtomicReference[Option[Interpreter]](None)
private val currentLine = new AtomicInteger(10000000)
def init(): Either[HeadlessConsoleError, Unit] = {
val options = transformer(baseOptions)
for {
interpreter <- Try(createInterpreter(options)).toEither.leftMap(
HeadlessConsole.RuntimeError("Failed to initialize console", _)
)
bindings <- consoleEnvironment.bindings.leftMap(
HeadlessConsole.RuntimeError("Unable to create the console bindings", _)
)
_ <- initializePredef(
interpreter,
bindings,
consoleEnvironment.predefCode(_),
logger,
)
} yield {
interpreterO.set(Some(interpreter))
}
}
private def runModule(
code: String,
path: Option[File] = None,
): Either[HeadlessConsoleError, Unit] =
for {
interpreter <- interpreterO
.get()
.toRight(HeadlessConsole.CompileError("Interpreter is not initialized"))
_ <- runCode(interpreter, code, path, logger)
} yield ()
def runLine(line: String): Either[HeadlessConsoleError, Unit] = for {
interpreter <- interpreterO
.get()
.toRight(HeadlessConsole.CompileError("Interpreter is not initialized"))
_ <- convertAmmoniteResult(
interpreter
.processExec(line, currentLine.incrementAndGet(), () => ()),
logger,
)
} yield ()
override def close(): Unit = {
lock.release()
}
}
/** Creates an interpreter but with matching bindings to the InteractiveConsole for running scripts non-interactively
*/
@SuppressWarnings(Array("org.wartremover.warts.Any"))
object HeadlessConsole extends NoTracing {
sealed trait HeadlessConsoleError
final case class CompileError(message: String) extends HeadlessConsoleError {
override def toString: String = message
}
final case class IoError(message: String) extends HeadlessConsoleError {
override def toString: String = message
}
final case class RuntimeError(message: String, cause: Throwable) extends HeadlessConsoleError {
override def toString: String = {
val messageWithSeparator = if (message.isEmpty) "" else message + " "
val exceptionInfo = ErrorUtil.messageWithStacktrace(cause)
messageWithSeparator + exceptionInfo
}
}
def run(
consoleEnvironment: ConsoleEnvironment,
code: String,
path: Option[File] = None,
transformer: Main => Main = identity,
logger: TracedLogger,
): Either[HeadlessConsoleError, Unit] = {
val console = new HeadlessConsole(consoleEnvironment, transformer, logger)
try {
for {
_ <- console.init()
_ <- console.runModule(code, path)
} yield ()
} finally {
console.close()
}
}
private def initializePredef(
interpreter: Interpreter,
bindings: IndexedSeq[Bind[_]],
interactivePredef: Boolean => String,
logger: TracedLogger,
): Either[HeadlessConsoleError, Unit] = {
val bindingsPredef = generateBindPredef(bindings)
val holder = Seq(
(
// This has to match the object name of the implementation that extends APIHolder[_}
objectClassNameWithoutSuffix(BindingsBridge.getClass),
"canton",
BindingsHolder(bindings),
)
)
val result = interpreter.initializePredef(
basePredefs = Seq(
PredefInfo(Name("BindingsPredef"), bindingsPredef, hardcoded = false, None),
PredefInfo(
Name("CantonImplicitPredef"),
interactivePredef(false),
hardcoded = false,
None,
),
),
customPredefs = Seq(),
extraBridges = holder,
)
// convert to an either and then map error if set to our own types
result.toLeft(()).left.map(err => convertAmmoniteError(err._1, logger))
}
private def runCode(
interpreter: Interpreter,
code: String,
path: Option[File],
logger: TracedLogger,
): Either[HeadlessConsoleError, Unit] = {
// the source details for our wrapper object that our code is compiled into
val source = Util.CodeSource(
wrapperName = Name("canton-script"),
flexiblePkgName = Seq(Name("interpreter")),
pkgRoot = Seq(Name("ammonite"), Name("canton")), // has to be rooted under ammonite
path.map(path => os.Path(path.getAbsolutePath)),
)
val result = interpreter.processModule(code, source, autoImport = false, "", hardcoded = false)
convertAmmoniteResult(result, logger)
}
/** Converts a return value from Ammonite into:
* - Unit if successful
* - Our own error hierarchy if the Ammonite error could be mapped
* @throws java.lang.RuntimeException If the value is unknown
*/
private def convertAmmoniteResult(
result: Res[_],
logger: TracedLogger,
): Either[HeadlessConsoleError, Unit] =
result match {
case Success(_) =>
Right(())
case failing: Failing => Left(convertAmmoniteError(failing, logger))
case unexpected =>
logger.error("Unexpected result from ammonite: {}", unexpected)
sys.error("Unexpected result from ammonite")
}
/** Converts a failing return value from Ammonite into our own error types.
* @throws java.lang.RuntimeException If the failing error is unknown
*/
private def convertAmmoniteError(result: Failing, logger: TracedLogger): HeadlessConsoleError =
result match {
case Failure(msg) => CompileError(msg)
case Exception(cause, msg) => RuntimeError(msg, cause)
case unexpected =>
logger.error("Unexpected error result from ammonite: {}", unexpected)
sys.error("Unexpected error result from ammonite")
}
@SuppressWarnings(Array("org.wartremover.warts.Null"))
private def createInterpreter(options: Main): Interpreter = {
val (colorsRef, printer) = Interpreter.initPrinters(
options.colors,
options.outputStream,
options.errorStream,
options.verboseOutput,
)
val frame = Frame.createInitial()
new Interpreter(
compilerBuilder = ammonite.compiler.CompilerBuilder,
parser = ammonite.compiler.Parsers,
printer = printer,
storage = options.storageBackend,
wd = options.wd,
colors = colorsRef,
verboseOutput = options.verboseOutput,
getFrame = () => frame,
createFrame = () => sys.error("Session loading / saving is not supported"),
initialClassLoader = null,
replCodeWrapper = options.replCodeWrapper,
scriptCodeWrapper = options.scriptCodeWrapper,
alreadyLoadedDependencies = options.alreadyLoadedDependencies,
)
}
private def generateBindPredef(binds: IndexedSeq[Bind[_]]): String =
binds.zipWithIndex
.map { case (b, idx) =>
s"""
|val ${b.name} = com.digitalasset.canton.console
| .BindingsBridge
| .value0
| .bindings($idx)
| .value
| .asInstanceOf[${b.typeTag.tpe}]
""".stripMargin
}
.mkString(System.lineSeparator)
}

View File

@ -0,0 +1,162 @@
// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.canton.console
import ammonite.compiler.Parsers
import ammonite.interp.Watchable
import ammonite.util.{Res, *}
import com.digitalasset.canton.CantonScript
import com.digitalasset.canton.logging.TracedLogger
import com.digitalasset.canton.tracing.NoTracing
import com.digitalasset.canton.util.ResourceUtil.withResource
import java.io.{File, InputStream}
import java.lang.System.lineSeparator
import scala.io.Source
import scala.util.Try
/** Will create a real REPL for interactive entry and evaluation of commands
*/
@SuppressWarnings(Array("org.wartremover.warts.Any"))
object InteractiveConsole extends NoTracing {
def apply(
consoleEnvironment: ConsoleEnvironment,
noTty: Boolean = false,
bootstrapScript: Option[CantonScript] = None,
logger: TracedLogger,
): Boolean = {
val (_lock, baseOptions) = AmmoniteConsoleConfig.create(
consoleEnvironment.environment.config.parameters.console,
// for including implicit conversions
predefCode =
consoleEnvironment.predefCode(interactive = true, noTty = noTty) + lineSeparator(),
welcomeBanner = Some(loadBanner()),
isRepl = true,
logger,
)
// where are never going to release the lock here
val options = baseOptions
// instead of using Main.run() from ammonite, we "inline"
// that code here as "startup" in order to include the
// bootstrap script in the beginning
// the issue is that most bootstrap scripts require the bound repl arguments
// (such as all, help, participant1, etc.), which are made available only here
// so we can't run Main.runScript or so as the "result" of the script are lost then
// in the REPL.
def startup(replArgs: IndexedSeq[Bind[_]]): (Res[Any], Seq[(Watchable, Long)]) = {
options.instantiateRepl(replArgs) match {
case Left(missingPredefInfo) => missingPredefInfo
case Right(repl) =>
repl.initializePredef().getOrElse {
// warm up the compilation
val warmupThread = new Thread(() => {
val _ = repl.warmup()
})
warmupThread.setDaemon(true)
warmupThread.start()
// load and run bootstrap script
val initRes = bootstrapScript.map(fname => {
// all we do is to write interp.load.module(...) into the console and let it interpret it
// the lines here are stolen from Repl.warmup()
logger.info(s"Running startup script $fname")
val loadModuleCode = fname.path
.map { (f: File) =>
// Try to move the script to a temp file, otherwise the name of the file can shadow scala variables in the script
Try {
val tmp = better.files.File.newTemporaryFile()
better.files.File(f.getAbsolutePath).copyTo(tmp, overwrite = true)
logger.debug(
s"Copied ${f.getAbsolutePath} to temporary file ${tmp.pathAsString}"
)
tmp.toJava
}.fold(
{ e =>
logger.debug(
s"Could not copy boostrap script to temp file, using original file",
e,
)
f
},
identity,
)
}
.map(p => "interp.load.module(os.Path(" + toStringLiteral(p.getAbsolutePath) + "))")
.getOrElse(fname.read().getOrElse(""))
val stmts = Parsers
.split(loadModuleCode)
.getOrElse(
sys.error("Expected parser to always return a success or failure")
) match { // `Parsers.split` returns an Option but should always be Some as we always provide code
case Left(error) => sys.error(s"Unable to parse code: $error")
case Right(parsed) => parsed
}
// if we run this with currentLine = 0, it will break the console output
repl.interp.processLine(loadModuleCode, stmts, 10000000, silent = true, () => ())
})
// now run the repl or exit if the bootstrap script failed
initRes match {
case Some(Res.Success(_)) | None =>
val exitValue = Res.Success(repl.run())
(exitValue.map(repl.beforeExit), repl.interp.watchedValues.toSeq)
case Some(a @ Res.Exception(x, y)) =>
val additionalMessage = if (y.isEmpty) "" else s", $y"
logger.error(
s"Running bootstrap script failed with an exception (${x.getMessage}$additionalMessage)!"
)
logger.debug("Ammonite exception thrown is", x)
(a, repl.interp.watchedValues.toSeq)
case Some(x) =>
logger.error(s"Running bootstrap script failed with ${x}")
(x, repl.interp.watchedValues.toSeq)
}
}
}
}
consoleEnvironment.bindings match {
case Left(exception) =>
System.err.println(exception.getMessage)
logger.debug("Unable to initialize the console bindings", exception)
false
case Right(bindings) =>
val (result, _) = startup(bindings)
result match {
// as exceptions are caught when in the REPL this is almost certainly from code in the predef
case Res.Exception(exception, _) =>
System.err.println(exception.getMessage)
logger.debug("Execution of interactive script returned exception", exception)
false
case Res.Failure(err) =>
System.err.println(err)
logger.debug(s"Execution of interactive script returned failure ${err}")
false
case _ =>
true
}
}
}
/** Turns the given String into a string literal suitable for including in scala code.
* Includes adding surrounding quotes.
* e.g. `some\\path` will return `"some\\\\path"`
*/
private def toStringLiteral(raw: String): String = {
// uses the scala reflection primitives but doesn't actually do any reflection
import scala.reflect.runtime.universe.*
Literal(Constant(raw)).toString()
}
private def loadBanner(): String = {
val stream: InputStream = Option(getClass.getClassLoader.getResourceAsStream("repl/banner.txt"))
.getOrElse(sys.error("banner resource not found"))
withResource(stream) { Source.fromInputStream(_).mkString }
}
}

Some files were not shown because too many files have changed in this diff Show More