diff --git a/.cspell.json b/.cspell.json index 37c45699434..61904e9e9bd 100644 --- a/.cspell.json +++ b/.cspell.json @@ -23,6 +23,7 @@ "Bools", "brioux", "cact", + "cactuts", "cactusf", "cafile", "caio", @@ -53,6 +54,7 @@ "Crpc", "CSDE", "csdetemplate", + "daml", "data", "davecgh", "dclm", diff --git a/packages/cactus-plugin-ledger-connector-daml/package.json b/packages/cactus-plugin-ledger-connector-daml/package.json new file mode 100644 index 00000000000..214ec583ff2 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-daml/package.json @@ -0,0 +1,98 @@ +{ + "name": "@hyperledger/cactus-plugin-ledger-connector-daml", + "version": "2.0.0-rc.3", + "description": "Allows Cactus nodes to connect to a DAML ledger.", + "keywords": [ + "Hyperledger", + "Cactus", + "Integration", + "Blockchain", + "Distributed Ledger Technology" + ], + "homepage": "https://github.com/hyperledger/cacti#readme", + "bugs": { + "url": "https://github.com/hyperledger/cacti/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hyperledger/cacti.git" + }, + "license": "Apache-2.0", + "author": { + "name": "Hyperledger Cactus Contributors", + "email": "cactus@lists.hyperledger.org", + "url": "https://www.hyperledger.org/use/cacti" + }, + "contributors": [ + { + "name": "Please add yourself to the list of contributors", + "email": "your.name@example.com", + "url": "https://example.com" + }, + { + "name": "Peter Somogyvari", + "email": "peter.somogyvari@accenture.com", + "url": "https://accenture.com" + } + ], + "main": "dist/lib/main/typescript/index.js", + "module": "dist/lib/main/typescript/index.js", + "browser": "dist/cactus-plugin-ledger-connector-corda.web.umd.js", + "types": "dist/lib/main/typescript/index.d.ts", + "files": [ + "dist/*" + ], + "scripts": { + "generate-sdk": "run-p 'generate-sdk:*'", + "generate-sdk:go": "openapi-generator-cli generate -i ./src/main/json/openapi.json -g go -o ./src/main/go/generated/openapi/go-client/ --git-user-id hyperledger --git-repo-id $(echo $npm_package_name | replace @hyperledger/ \"\" -z)/src/main/go/generated/openapi/go-client --package-name $(echo $npm_package_name | replace @hyperledger/ \"\" -z) --reserved-words-mappings protected=protected --ignore-file-override ../../openapi-generator-ignore", + "generate-sdk:kotlin": "openapi-generator-cli generate -i ./src/main/json/openapi.json -g kotlin -o ./src/main/kotlin/generated/openapi/kotlin-client/ --reserved-words-mappings protected=protected --ignore-file-override ../../openapi-generator-ignore", + "generate-sdk:typescript-axios": "openapi-generator-cli generate -i ./src/main/json/openapi.json -g typescript-axios -o ./src/main/typescript/generated/openapi/typescript-axios/ --ignore-file-override ../../openapi-generator-ignore", + "generate-server": "yarn run --top-level openapi-generator-cli generate -i ./src/main/json/openapi.json -g kotlin-spring -o ./src/main-server/kotlin/gen/kotlin-spring/ -c ./src/main-server/openapi-generator-config.yaml --ignore-file-override ../../openapi-generator-ignore", + "watch": "npm-watch", + "webpack": "npm-run-all webpack:dev", + "webpack:dev": "npm-run-all webpack:dev:node webpack:dev:web", + "webpack:dev:node": "webpack --env=dev --target=node --config ../../webpack.config.js", + "webpack:dev:web": "webpack --env=dev --target=web --config ../../webpack.config.js" + }, + "dependencies": { + "@hyperledger/cactus-common": "2.0.0-rc.3", + "@hyperledger/cactus-core": "2.0.0-rc.3", + "@hyperledger/cactus-core-api": "2.0.0-rc.3", + "axios": "1.6.0", + "express-openapi-validator": "5.2.0", + "internal-ip": "6.2.0", + "joi": "17.13.3", + "node-ssh": "13.1.0", + "prom-client": "15.1.3", + "rxjs": "7.8.1", + "temp": "0.9.4", + "typescript-optional": "2.0.1" + }, + "devDependencies": { + "@hyperledger/cactus-test-tooling": "2.0.0-rc.3", + "@types/body-parser": "1.19.4", + "@types/express": "4.17.21", + "@types/multer": "1.4.7", + "@types/temp": "0.9.1", + "@types/uuid": "10.0.0", + "body-parser": "1.20.2", + "express": "4.19.2", + "uuid": "10.0.0" + }, + "engines": { + "node": ">=18", + "npm": ">=8" + }, + "publishConfig": { + "access": "public" + }, + "browserMinified": "dist/cactus-plugin-ledger-connector-corda.web.umd.min.js", + "mainMinified": "dist/cactus-plugin-ledger-connector-corda.node.umd.min.js", + "watch": { + "codegen:openapi": { + "patterns": [ + "./src/main/json/openapi.json" + ] + } + } +} diff --git a/packages/cactus-plugin-ledger-connector-daml/src/test/typescript/integration/daml-get-transaction.test.ts b/packages/cactus-plugin-ledger-connector-daml/src/test/typescript/integration/daml-get-transaction.test.ts new file mode 100644 index 00000000000..490c5ff23ce --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-daml/src/test/typescript/integration/daml-get-transaction.test.ts @@ -0,0 +1,58 @@ +import "jest-extended"; +import { DamlTestLedger } from "@hyperledger/cactus-test-tooling"; + +import { + LogLevelDesc, + LoggerProvider, + Logger, +} from "@hyperledger/cactus-common"; +import { pruneDockerAllIfGithubAction } from "../../../../../../packages/cactus-test-tooling/src/main/typescript/github-actions/prune-docker-all-if-github-action"; +//Constants +const containerImageVersion = "latest"; +const containerImageName = "cactuts/daml-all-in-one"; +const rpcApiHttpPort = 7575; + +const testLogLevel: LogLevelDesc = "info"; + +// Logger setup +const log: Logger = LoggerProvider.getOrCreate({ + label: "daml-get-transaction.test", + level: testLogLevel, +}); + +describe("PluginLedgerConnectorDAML", () => { + let damlTestLedger: DamlTestLedger; + + beforeAll(async () => { + log.info("Prune Docker..."); + await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + + damlTestLedger = new DamlTestLedger({ + containerImageVersion, + containerImageName, + rpcApiHttpPort, + }); + + log.debug("DAML image:", damlTestLedger.containerImageName); + expect(damlTestLedger).toBeTruthy(); + await damlTestLedger.start(); + }); + + afterAll(async () => { + log.info("FINISHING THE TESTS"); + if (damlTestLedger) { + log.info("Stop the DAML ledger..."); + await damlTestLedger.stop(); + await damlTestLedger.destroy(); + } + + log.info("Prune Docker..."); + await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + }); + + describe("daml test transaction", () => { + it("daml test transaction", async () => { + console.log("Initial run success"); + }); + }); +}); 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 00000000000..6c96e831c49 --- /dev/null +++ b/packages/cactus-test-tooling/src/main/typescript/daml/daml-test-ledger.ts @@ -0,0 +1,354 @@ +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 DAMLTestLedgerConstructorOptions { + containerImageVersion?: string; + containerImageName?: string; + rpcApiHttpPort?: number; + logLevel?: LogLevelDesc; + emitContainerLogs?: boolean; +} + +export const DAML_TEST_LEDGER_DEFAULT_OPTIONS = Object.freeze({ + containerImageVersion: "latest", + containerImageName: "cactuts/daml-all-in-one", + rpcApiHttpPort: 7575, +}); + +export const DAML_TEST_LEDGER_OPTIONS_JOI_SCHEMA: Joi.Schema = + Joi.object().keys({ + containerImageVersion: Joi.string().min(5).required(), + containerImageName: Joi.string().min(1).required(), + rpcApiHttpPort: Joi.number() + .integer() + .positive() + .min(1024) + .max(65535) + .required(), + }); + +export class DamlTestLedger implements ITestLedger { + public readonly containerImageVersion: string; + public readonly containerImageName: string; + public readonly rpcApiHttpPort: number; + public readonly emitContainerLogs: boolean; + + private readonly log: Logger; + private container: Container | undefined; + private containerId: string | undefined; + + constructor(public readonly options: DAMLTestLedgerConstructorOptions = {}) { + if (!options) { + throw new TypeError(`DAMLTestLedger#ctor options was falsy.`); + } + this.containerImageVersion = + options.containerImageVersion || + DAML_TEST_LEDGER_DEFAULT_OPTIONS.containerImageVersion; + this.containerImageName = + options.containerImageName || + DAML_TEST_LEDGER_DEFAULT_OPTIONS.containerImageName; + this.rpcApiHttpPort = + options.rpcApiHttpPort || DAML_TEST_LEDGER_DEFAULT_OPTIONS.rpcApiHttpPort; + + this.emitContainerLogs = Bools.isBooleanStrict(options.emitContainerLogs) + ? (options.emitContainerLogs as boolean) + : true; + + this.validateConstructorOptions(); + const label = "daml-test-ledger"; + const level = options.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.containerImageName}:${this.containerImageVersion}`; + } + + public async getRpcApiHttpHost(): Promise { + const ipAddress = "127.0.0.1"; + const hostPort: number = await this.getRpcApiPublicPort(); + return `http://${ipAddress}:${hostPort}`; + } + + public async getDamlApiHost(): Promise { + const ipAddress = "127.0.0.1"; + const port = "7575"; + return `http://${ipAddress}:${port}`; + } + + 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 = 144000): 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.containerImageName}`); + } + } + + 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({ + containerImageVersion: this.containerImageVersion, + containerImageName: this.containerImageName, + 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 ec19ee73243..d390575726c 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,8 @@ export { IBesuMpTestLedgerOptions, } from "./besu/besu-mp-test-ledger"; +export { DamlTestLedger } 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 59da60495d6..d83f3cf8000 100644 --- a/tools/docker/daml-all-in-one/Dockerfile +++ b/tools/docker/daml-all-in-one/Dockerfile @@ -27,6 +27,7 @@ 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 b78bb975362..f391ca908dc 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 diff --git a/yarn.lock b/yarn.lock index 13638456aee..81da2ca89db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10354,6 +10354,34 @@ __metadata: languageName: unknown linkType: soft +"@hyperledger/cactus-plugin-ledger-connector-daml@workspace:packages/cactus-plugin-ledger-connector-daml": + version: 0.0.0-use.local + resolution: "@hyperledger/cactus-plugin-ledger-connector-daml@workspace:packages/cactus-plugin-ledger-connector-daml" + dependencies: + "@hyperledger/cactus-common": "npm:2.0.0-rc.3" + "@hyperledger/cactus-core": "npm:2.0.0-rc.3" + "@hyperledger/cactus-core-api": "npm:2.0.0-rc.3" + "@hyperledger/cactus-test-tooling": "npm:2.0.0-rc.3" + "@types/body-parser": "npm:1.19.4" + "@types/express": "npm:4.17.21" + "@types/multer": "npm:1.4.7" + "@types/temp": "npm:0.9.1" + "@types/uuid": "npm:10.0.0" + axios: "npm:1.6.0" + body-parser: "npm:1.20.2" + express: "npm:4.19.2" + express-openapi-validator: "npm:5.2.0" + internal-ip: "npm:6.2.0" + joi: "npm:17.13.3" + node-ssh: "npm:13.1.0" + prom-client: "npm:15.1.3" + rxjs: "npm:7.8.1" + temp: "npm:0.9.4" + typescript-optional: "npm:2.0.1" + uuid: "npm:10.0.0" + languageName: unknown + linkType: soft + "@hyperledger/cactus-plugin-ledger-connector-ethereum@npm:2.0.0-rc.3, @hyperledger/cactus-plugin-ledger-connector-ethereum@workspace:packages/cactus-plugin-ledger-connector-ethereum": version: 0.0.0-use.local resolution: "@hyperledger/cactus-plugin-ledger-connector-ethereum@workspace:packages/cactus-plugin-ledger-connector-ethereum"