diff --git a/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts b/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts new file mode 100644 index 00000000000..4ca380db95e --- /dev/null +++ b/packages/cactus-test-tooling/src/main/typescript/openethereum/openethereum-test-ledger.ts @@ -0,0 +1,206 @@ +import { EventEmitter } from "events"; +import Docker, { Container } from "dockerode"; +import { v4 as internalIpV4 } from "internal-ip"; + +import { + Logger, + Checks, + LogLevelDesc, + LoggerProvider, + Bools, +} from "@hyperledger/cactus-common"; + +import { Containers } from "../common/containers"; + +export interface IOpenEthereumTestLedgerOptions { + envVars?: string[]; + imageVersion?: string; + imageName?: string; + chainId?: string; + logLevel?: LogLevelDesc; + emitContainerLogs?: boolean; + chain?: string; + httpPort?: number; +} + +export const K_DEFAULT_OPEN_ETHEREUM_IMAGE_NAME = "openethereum/openethereum"; +export const K_DEFAULT_OPEN_ETHEREUM_IMAGE_VERSION = "v3.2.4"; +export const K_DEFAULT_OPEN_ETHEREUM_HTTP_PORT = 8545; +// @see https://openethereum.github.io/Chain-specification +// @see https://github.com/openethereum/openethereum/tree/main/crates/ethcore/res/chainspec +export const K_DEFAULT_OPEN_ETHEREUM_CHAIN = "dev"; + +// @see https://openethereum.github.io/Private-development-chain +export const K_DEV_WHALE_ACCOUNT_PRIVATE_KEY = + "4d5db4107d237df6a3d58ee5f70ae63d73d7658d4026f2eefd2f204c81682cb7"; +export const K_DEV_WHALE_ACCOUNT_PUBLIC_KEY = + "00a329c0648769a73afac7f9381e08fb43dbea72"; + +/** + * Class responsible for programmatically managing a container that is running + * the image made for hosting a keycloak instance which can be used to test + * authorization/authentication related use-cases. + */ +export class OpenEthereumTestLedger { + public static readonly CLASS_NAME = "OpenEthereumTestLedger"; + private readonly log: Logger; + private readonly imageName: string; + private readonly imageVersion: string; + private readonly envVars: string[]; + private readonly emitContainerLogs: boolean; + private readonly chain: string; + private readonly httpPort: number; + private _container: Container | undefined; + private _containerId: string | undefined; + + public get imageFqn(): string { + return `${this.imageName}:${this.imageVersion}`; + } + + public get className(): string { + return OpenEthereumTestLedger.CLASS_NAME; + } + + public get container(): Container { + if (this._container) { + return this._container; + } else { + throw new Error(`Invalid state: _container is not set. Called start()?`); + } + } + + constructor(public readonly options: IOpenEthereumTestLedgerOptions) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + + const level = this.options.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + + this.emitContainerLogs = Bools.isBooleanStrict(options.emitContainerLogs) + ? (options.emitContainerLogs as boolean) + : true; + + this.chain = this.options.chain || K_DEFAULT_OPEN_ETHEREUM_CHAIN; + this.httpPort = this.options.httpPort || K_DEFAULT_OPEN_ETHEREUM_HTTP_PORT; + this.imageName = + this.options.imageName || K_DEFAULT_OPEN_ETHEREUM_IMAGE_NAME; + this.imageVersion = + this.options.imageVersion || K_DEFAULT_OPEN_ETHEREUM_IMAGE_VERSION; + this.envVars = this.options.envVars || []; + + this.log.info(`Created ${this.className} OK. Image FQN: ${this.imageFqn}`); + } + + public async start(): Promise { + if (this._container) { + await this.container.stop(); + await this.container.remove(); + } + const docker = new Docker(); + + await Containers.pullImage(this.imageFqn); + + const Env = [...[], ...this.envVars]; + this.log.debug(`Effective Env of container: %o`, Env); + + const apiUrl = await this.getRpcApiHttpHost("localhost", this.httpPort); + const Healthcheck = { + Test: ["CMD-SHELL", `curl -v '${apiUrl}'`], + Interval: 1000000000, // 1 second + Timeout: 3000000000, // 3 seconds + Retries: 99, + StartPeriod: 1000000000, // 1 second + }; + + const cmd = [ + "--chain=" + this.chain, + "--no-persistent-txqueue", // Don't save pending local transactions to disk to be restored whenever the node restarts. + "--jsonrpc-port=" + this.httpPort, + "--jsonrpc-cors=all", + "--jsonrpc-interface=all", + "--jsonrpc-hosts=all", + "--jsonrpc-apis=web3,eth,net,parity", + "--ws-interface=all", + "--ws-apis=web3,eth,net,parity,pubsub", + "--ws-origins=all", + "--ws-hosts=all", + "--ws-max-connections=10", + "--max-peers=100", + ]; + + return new Promise((resolve, reject) => { + const eventEmitter: EventEmitter = docker.run( + this.imageFqn, + [...cmd], + [], + { + Env, + PublishAllPorts: true, + Healthcheck, + }, + {}, + (err?: Error) => { + if (err) { + this.log.error(`Failed to start ${this.imageFqn} container; `, err); + reject(err); + } + }, + ); + + eventEmitter.once("start", async (container: Container) => { + this._container = container; + this._containerId = container.id; + if (this.emitContainerLogs) { + const logOptions = { follow: true, stderr: true, stdout: true }; + const logStream = await container.logs(logOptions); + logStream.on("data", (data: Buffer) => { + this.log.debug(`[${this.imageFqn}] %o`, data.toString("utf-8")); + }); + } + try { + await Containers.waitForHealthCheck(this._containerId); + resolve(container); + } catch (ex) { + reject(ex); + } + }); + }); + } + + public async getRpcApiHttpHost( + host?: string, + port?: number, + ): Promise { + const thePort = port || (await this.getHostPortHttp()); + const lanIpV4OrUndefined = await internalIpV4(); + const lanAddress = host || lanIpV4OrUndefined || "127.0.0.1"; // best effort... + return `http://${lanAddress}:${thePort}`; + } + + public async stop(): Promise { + if (this._container) { + await Containers.stop(this.container); + } + } + + public destroy(): Promise { + const fnTag = `${this.className}#destroy()`; + if (this._container) { + return this._container.remove(); + } else { + const ex = new Error(`${fnTag} Container not found, nothing to destroy.`); + return Promise.reject(ex); + } + } + + public async getHostPortHttp(): Promise { + const fnTag = `${this.className}#getHostPortHttp()`; + if (this._containerId) { + const cInfo = await Containers.getById(this._containerId); + return Containers.getPublicPort(this.httpPort, cInfo); + } else { + throw new Error(`${fnTag} Container ID not set. Did you call start()?`); + } + } +} 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 df059278145..1de8186042d 100755 --- a/packages/cactus-test-tooling/src/main/typescript/public-api.ts +++ b/packages/cactus-test-tooling/src/main/typescript/public-api.ts @@ -77,6 +77,17 @@ export { KeycloakContainer, } from "./keycloak/keycloak-container"; +export { + IOpenEthereumTestLedgerOptions, + K_DEFAULT_OPEN_ETHEREUM_HTTP_PORT, + K_DEFAULT_OPEN_ETHEREUM_IMAGE_NAME, + K_DEFAULT_OPEN_ETHEREUM_IMAGE_VERSION, + K_DEFAULT_OPEN_ETHEREUM_CHAIN, + K_DEV_WHALE_ACCOUNT_PRIVATE_KEY, + K_DEV_WHALE_ACCOUNT_PUBLIC_KEY, + OpenEthereumTestLedger, +} from "./openethereum/openethereum-test-ledger"; + export { SAMPLE_CORDAPP_ROOT_DIRS, SampleCordappEnum,