diff --git a/.cspell.json b/.cspell.json index a02135d36e..1c88ffd414 100644 --- a/.cspell.json +++ b/.cspell.json @@ -54,6 +54,7 @@ "Crpc", "CSDE", "csdetemplate", + "daml", "data", "davecgh", "dclm", diff --git a/packages/cactus-test-tooling/src/main/typescript/daml/daml-test-ledger.ts b/packages/cactus-test-tooling/src/main/typescript/daml/daml-test-ledger.ts new file mode 100644 index 0000000000..b42c09077e --- /dev/null +++ b/packages/cactus-test-tooling/src/main/typescript/daml/daml-test-ledger.ts @@ -0,0 +1,345 @@ +import Docker, { Container, ContainerInfo } from "dockerode"; +import Joi from "joi"; +import tar from "tar-stream"; +import { EventEmitter } from "events"; +import { + LogLevelDesc, + Logger, + LoggerProvider, + Bools, +} from "@hyperledger/cactus-common"; +import { ITestLedger } from "../i-test-ledger"; +import { Streams } from "../common/streams"; +import { Containers } from "../common/containers"; + +export interface IDamlTestLedgerOptions { + imageVersion?: string; + imageName?: string; + rpcApiHttpPort?: number; + logLevel?: LogLevelDesc; + emitContainerLogs?: boolean; +} + +const DEFAULTS = Object.freeze({ + imageVersion: "2024-09-08T07-40-07-dev-2cc217b7a", + imageName: "ghcr.io/hyperledger/cacti-daml-all-in-one", + rpcApiHttpPort: 7575, +}); + +export const DAML_TEST_LEDGER_DEFAULT_OPTIONS = DEFAULTS; + +export const DAML_TEST_LEDGER_OPTIONS_JOI_SCHEMA: Joi.Schema = + Joi.object().keys({ + imageVersion: Joi.string().min(5).required(), + imageName: Joi.string().min(1).required(), + rpcApiHttpPort: Joi.number() + .integer() + .positive() + .min(1024) + .max(65535) + .required(), + }); + +export class DamlTestLedger implements ITestLedger { + public readonly imageVersion: string; + public readonly imageName: string; + public readonly rpcApiHttpPort: number; + public readonly emitContainerLogs: boolean; + + private readonly log: Logger; + private container: Container | undefined; + private containerId: string | undefined; + + constructor(public readonly opts?: IDamlTestLedgerOptions) { + if (!opts) { + throw new TypeError(`DAMLTestLedger#ctor options was falsy.`); + } + this.imageVersion = opts.imageVersion || DEFAULTS.imageVersion; + this.imageName = opts.imageName || DEFAULTS.imageName; + this.rpcApiHttpPort = opts.rpcApiHttpPort || DEFAULTS.rpcApiHttpPort; + + this.emitContainerLogs = Bools.isBooleanStrict(opts.emitContainerLogs) + ? (opts.emitContainerLogs as boolean) + : true; + + this.validateConstructorOptions(); + const label = "daml-test-ledger"; + const level = opts.logLevel || "INFO"; + this.log = LoggerProvider.getOrCreate({ level, label }); + } + + public getContainer(): Container { + const fnTag = "DAMLTestLedger#getContainer()"; + if (!this.container) { + throw new Error(`${fnTag} container not yet started by this instance.`); + } else { + return this.container; + } + } + + public getContainerImageName(): string { + return `${this.imageName}:${this.imageVersion}`; + } + + public async getRpcApiHttpHost(): Promise { + const ipAddress = "127.0.0.1"; + const hostPort: number = await this.getRpcApiPublicPort(); + return `http://${ipAddress}:${hostPort}`; + } + + public async getFileContents(filePath: string): Promise { + const response = await this.getContainer().getArchive({ + path: filePath, + }); + const extract: tar.Extract = tar.extract({ autoDestroy: true }); + + return new Promise((resolve, reject) => { + let fileContents = ""; + extract.on("entry", async (header: unknown, stream, next) => { + stream.on("error", (err: Error) => { + reject(err); + }); + const chunks: string[] = await Streams.aggregate(stream); + fileContents += chunks.join(""); + stream.resume(); + next(); + }); + + extract.on("finish", () => { + resolve(fileContents); + }); + + response.pipe(extract); + }); + } + + public async start(omitPull = false): Promise { + const imageFqn = this.getContainerImageName(); + + if (this.container) { + await this.container.stop(); + await this.container.remove(); + } + const docker = new Docker(); + + if (!omitPull) { + this.log.debug(`Pulling container image ${imageFqn} ...`); + await this.pullContainerImage(imageFqn); + this.log.debug(`Pulled ${imageFqn} OK. Starting container...`); + } + + return new Promise((resolve, reject) => { + const eventEmitter: EventEmitter = docker.run( + imageFqn, + [], + [], + { + NetworkMode: "host", + ExposedPorts: { + "7575/tcp": {}, // DAML http endpoint + }, + HostConfig: { + PublishAllPorts: true, + PortBindings: { + "7575/tcp": [ + { + HostPort: "7575", //change the default port of docker back to 7575 + }, + ], + }, + }, + }, + {}, + (err: unknown) => { + if (err) { + reject(err); + } + }, + ); + + eventEmitter.once("start", async (container: Container) => { + this.log.debug(`Started container OK. Waiting for healthcheck...`); + this.container = container; + this.containerId = container.id; + + if (this.emitContainerLogs) { + const fnTag = `[${this.getContainerImageName()}]`; + await Containers.streamLogs({ + container: this.getContainer(), + tag: fnTag, + log: this.log, + }); + } + + try { + await this.waitForHealthCheck(); + this.log.debug(`Healthcheck passing OK.`); + resolve(container); + } catch (ex) { + reject(ex); + } + }); + }); + } + + public async waitForHealthCheck(timeoutMs = 360000): Promise { + const fnTag = "DAMLTestLedger#waitForHealthCheck()"; + const startedAt = Date.now(); + let isHealthy = false; + do { + if (Date.now() >= startedAt + timeoutMs) { + throw new Error(`${fnTag} timed out (${timeoutMs}ms)`); + } + const { Status, State } = await this.getContainerInfo(); + this.log.debug(`ContainerInfo.Status=%o, State=O%`, Status, State); + isHealthy = Status.endsWith("(healthy)"); + if (!isHealthy) { + await new Promise((resolve2) => setTimeout(resolve2, 1000)); + } + } while (!isHealthy); + } + + public stop(): Promise { + const fnTag = "DAMLTestLedger#stop()"; + return new Promise((resolve, reject) => { + if (this.container) { + this.container.stop({}, (err: unknown, result: unknown) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + } else { + return reject(new Error(`${fnTag} Container was not running.`)); + } + }); + } + + public destroy(): Promise { + const fnTag = "DAMLTestLedger#destroy()"; + if (this.container) { + return this.container.remove(); + } else { + const ex = new Error(`${fnTag} Container not found, nothing to destroy.`); + return Promise.reject(ex); + } + } + + protected async getContainerInfo(): Promise { + const docker = new Docker(); + const image = this.getContainerImageName(); + const containerInfos = await docker.listContainers({}); + + let aContainerInfo; + if (this.containerId !== undefined) { + aContainerInfo = containerInfos.find((ci) => ci.Id === this.containerId); + } + + if (aContainerInfo) { + return aContainerInfo; + } else { + throw new Error(`DAMLTestLedger#getContainerInfo() no image "${image}"`); + } + } + + public async getRpcApiPublicPort(): Promise { + const fnTag = "DAMLTestLedger#getRpcApiPublicPort()"; + const aContainerInfo = await this.getContainerInfo(); + const { rpcApiHttpPort: thePort } = this; + const { Ports: ports } = aContainerInfo; + + if (ports.length < 1) { + throw new Error(`${fnTag} no ports exposed or mapped at all`); + } + const mapping = ports.find((x) => x.PrivatePort === thePort); + if (mapping) { + if (!mapping.PublicPort) { + throw new Error(`${fnTag} port ${thePort} mapped but not public`); + } else if (mapping.IP !== "0.0.0.0") { + throw new Error(`${fnTag} port ${thePort} mapped to 127.0.0.1`); + } else { + return mapping.PublicPort; + } + } else { + throw new Error(`${fnTag} no mapping found for ${thePort}`); + } + } + public async getDamlAuthorizationToken(): Promise { + const docker = new Docker(); + const aContainerInfo = await this.getContainerInfo(); + const containerId = aContainerInfo.Id; + const exec = await docker.getContainer(containerId).exec({ + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Cmd: ["/bin/bash", "-c", "cat jwt"], // Command to execute + }); + const stream = await exec.start({}); + + return new Promise((resolve, reject) => { + let output = ""; + stream.on("data", (data: Buffer) => { + output += data.toString(); // Accumulate the output + resolve(output); + }); + stream.on("error", (err: Error) => { + reject(err); + }); + }); + } + + public async getContainerIpAddress(): Promise { + const fnTag = "DAMLTestLedger#getContainerIpAddress()"; + const aContainerInfo = await this.getContainerInfo(); + + if (aContainerInfo) { + const { NetworkSettings } = aContainerInfo; + const networkNames: string[] = Object.keys(NetworkSettings.Networks); + if (networkNames.length < 1) { + throw new Error(`${fnTag} container not connected to any networks`); + } else { + return NetworkSettings.Networks[networkNames[0]].IPAddress; + } + } else { + throw new Error(`${fnTag} cannot find image: ${this.imageName}`); + } + } + + private pullContainerImage(containerNameAndTag: string): Promise { + return new Promise((resolve, reject) => { + const docker = new Docker(); + docker.pull(containerNameAndTag, (pullError: unknown, stream: never) => { + if (pullError) { + reject(pullError); + } else { + docker.modem.followProgress( + stream, + (progressError: unknown, output: unknown[]) => { + if (progressError) { + reject(progressError); + } else { + resolve(output); + } + }, + ); + } + }); + }); + } + + private validateConstructorOptions(): void { + const validationResult = DAML_TEST_LEDGER_OPTIONS_JOI_SCHEMA.validate({ + imageVersion: this.imageVersion, + imageName: this.imageName, + rpcApiHttpPort: this.rpcApiHttpPort, + }); + + if (validationResult.error) { + throw new Error( + `DAMLTestLedger#ctor ${validationResult.error.annotate()}`, + ); + } + } +} diff --git a/packages/cactus-test-tooling/src/main/typescript/public-api.ts b/packages/cactus-test-tooling/src/main/typescript/public-api.ts index 25b85a43c9..6054d279b0 100755 --- a/packages/cactus-test-tooling/src/main/typescript/public-api.ts +++ b/packages/cactus-test-tooling/src/main/typescript/public-api.ts @@ -13,6 +13,12 @@ export { IBesuMpTestLedgerOptions, } from "./besu/besu-mp-test-ledger"; +export { + DamlTestLedger, + DAML_TEST_LEDGER_DEFAULT_OPTIONS, + IDamlTestLedgerOptions, +} from "./daml/daml-test-ledger"; + export { CordaTestLedger, ICordaTestLedgerConstructorOptions, diff --git a/tools/docker/daml-all-in-one/Dockerfile b/tools/docker/daml-all-in-one/Dockerfile index 59da60495d..8abb29185f 100644 --- a/tools/docker/daml-all-in-one/Dockerfile +++ b/tools/docker/daml-all-in-one/Dockerfile @@ -1,15 +1,16 @@ FROM ubuntu:22.04 -RUN apt update -RUN apt install curl openjdk-21-jdk -y +LABEL org.opencontainers.image.source = "https://github.com/hyperledger/cacti" +RUN apt update && \ + apt install --no-install-recommends curl openjdk-21-jdk supervisor openssl xxd -y # Download and install DAML SDK 2.9.3 RUN curl -L https://github.com/digital-asset/daml/releases/download/v2.9.3/daml-sdk-2.9.3-linux.tar.gz | tar -xz -C /opt && \ cd /opt/sdk-2.9.3 && \ - ./install.sh + ./install.sh && \ + rm -rf /opt/sdk-2.9.3/ ENV PATH="/root/.daml/bin:${PATH}" -RUN apt-get install xxd RUN daml new quickstart --template quickstart-java WORKDIR /quickstart @@ -17,16 +18,15 @@ WORKDIR /quickstart RUN echo '{"server": {"address": "0.0.0.0","port": 7575},"ledger-api": {"address": "0.0.0.0","port": 6865}}' > json-api-app.conf # Run the auto generation of Authorization Bearer Token -RUN apt-get update && apt-get install -y openssl COPY generate-jwt-token.sh /quickstart/generate-jwt-token.sh RUN chmod +x /quickstart/generate-jwt-token.sh RUN /quickstart/generate-jwt-token.sh -RUN apt-get update && apt-get install -y supervisor RUN mkdir -p /var/log/supervisor COPY supervisord.conf /etc/supervisord.conf EXPOSE 9001 +EXPOSE 7575 ENTRYPOINT ["/usr/bin/supervisord"] CMD ["--configuration","/etc/supervisord.conf", "--nodaemon"] diff --git a/tools/docker/daml-all-in-one/README.md b/tools/docker/daml-all-in-one/README.md index b78bb97536..f391ca908d 100644 --- a/tools/docker/daml-all-in-one/README.md +++ b/tools/docker/daml-all-in-one/README.md @@ -26,5 +26,4 @@ The following ports are open on the container: ``` ## Logs of DAML via supervisord web UI: -Navigate your browser to http://localhost:9001 - +Navigate your browser to http://localhost:9001 \ No newline at end of file