UNPKG

@holochain/tryorama

Version:

Toolset to manage Holochain conductors and facilitate running test scenarios

325 lines (324 loc) 12.2 kB
import { AdminWebsocket, AppWebsocket, encodeHashToBase64, getSigningCredentials, } from "@holochain/client"; import getPort, { portNumbers } from "get-port"; import yaml from "js-yaml"; import pick from "lodash/pick.js"; import assert from "node:assert"; import { spawn } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { URL } from "node:url"; import { v4 as uuidv4 } from "uuid"; import { _ALLOWED_ORIGIN } from "./conductor-helpers.js"; import { makeLogger } from "./logger.js"; const logger = makeLogger(); /** * @public */ export const CONDUCTOR_CONFIG = "conductor-config.yaml"; const HOST_URL = new URL("ws://localhost"); const DEFAULT_TIMEOUT = 60000; const LAIR_PASSWORD = "lair-password"; /** * The function to create a conductor. It starts a sandbox conductor via the * Holochain CLI. * * @returns A conductor instance. * * @public */ export const createConductor = async (signalingServerUrl, options) => { const createConductorOptions = pick(options, [ "bootstrapServerUrl", "timeout", ]); const conductor = await Conductor.create(signalingServerUrl, createConductorOptions); const networkConfig = pick(options, [ "initiateIntervalMs", "minInitiateIntervalMs", "initiateJitterMs", "roundTimeoutMs", "transportTimeoutS", ]); conductor.setNetworkConfig(networkConfig); if (options?.startup !== false) { await conductor.startUp(); } return conductor; }; /** * A class to manage a conductor running on localhost. * * @public */ export class Conductor { conductorProcess; conductorDir; adminApiUrl; _adminWs; _appWs; timeout; constructor(timeout) { this.conductorProcess = undefined; this.conductorDir = undefined; this.adminApiUrl = new URL(HOST_URL.href); this._adminWs = undefined; this._appWs = undefined; this.timeout = timeout ?? DEFAULT_TIMEOUT; } /** * Factory to create a conductor. * * @returns A configured instance of a conductor, not yet running. */ static async create(signalingServerUrl, options) { const args = ["sandbox", "--piped", "create", "--in-process-lair"]; args.push("network"); if (options?.bootstrapServerUrl) { args.push("--bootstrap", options.bootstrapServerUrl.href); } args.push("webrtc"); args.push(signalingServerUrl.href); logger.debug("spawning hc sandbox with args:", args); const createConductorProcess = spawn("hc", args); createConductorProcess.stdin.write(LAIR_PASSWORD); createConductorProcess.stdin.end(); const conductor = new Conductor(options?.timeout); return new Promise((resolve, reject) => { createConductorProcess.stdout.on("data", (data) => { logger.debug(`creating conductor config\n${data.toString()}`); const tmpDirMatches = [ ...data.toString().matchAll(/ConfigRootPath\("(.*?)"\)/g), ]; if (tmpDirMatches.length) { conductor.conductorDir = tmpDirMatches[0][1]; } }); createConductorProcess.stdout.on("end", () => { resolve(conductor); }); createConductorProcess.stderr.on("data", (err) => { logger.error(`error when creating conductor config: ${err}\n`); reject(err); }); }); } setNetworkConfig(createConductorOptions) { const conductorConfig = readFileSync(`${this.conductorDir}/${CONDUCTOR_CONFIG}`, "utf-8"); const conductorConfigYaml = yaml.load(conductorConfig); assert(conductorConfigYaml && typeof conductorConfigYaml === "object"); assert("network" in conductorConfigYaml && conductorConfigYaml.network && typeof conductorConfigYaml.network === "object"); if ("mem_bootstrap" in conductorConfigYaml.network) { delete conductorConfigYaml.network.mem_bootstrap; } assert("advanced" in conductorConfigYaml.network); conductorConfigYaml.network.advanced = { k2Gossip: { initiateIntervalMs: createConductorOptions.initiateIntervalMs ?? 100, minInitiateIntervalMs: createConductorOptions.minInitiateIntervalMs ?? 100, initiateJitterMs: createConductorOptions.initiateJitterMs ?? 30, roundTimeoutMs: createConductorOptions.roundTimeoutMs ?? 10_000, }, tx5Transport: { signalAllowPlainText: true, timeoutS: createConductorOptions.transportTimeoutS ?? 15, }, }; const yamlDump = yaml.dump(conductorConfigYaml); logger.debug("Updated conductor config:"); logger.debug(yamlDump); writeFileSync(`${this.conductorDir}/${CONDUCTOR_CONFIG}`, yamlDump); } /** * Start the conductor and establish a web socket connection to the Admin * API. */ async startUp() { assert(this.conductorDir, "error starting conductor: conductor has not been created"); if (this.conductorProcess) { logger.error("error starting conductor: conductor is already running\n"); return; } const runConductorProcess = spawn("hc", [ "sandbox", "--piped", "run", "-e", this.conductorDir, ]); runConductorProcess.stdin.write(LAIR_PASSWORD); runConductorProcess.stdin.end(); const startPromise = new Promise((resolve) => { runConductorProcess.stdout.on("data", (data) => { logger.info(data.toString()); const conductorLaunched = data .toString() .match(/Conductor launched #!\d ({.*})/); if (conductorLaunched) { // This is the last output of the startup process. const portConfiguration = JSON.parse(conductorLaunched[1]); this.adminApiUrl.port = portConfiguration.admin_port; this.conductorProcess = runConductorProcess; resolve(); } }); runConductorProcess.stderr.on("data", (data) => { logger.error(data.toString()); }); }); await startPromise; await this.connectAdminWs(); } /** * Close Admin and App API connections and kill the conductor process. */ async shutDown() { if (!this.conductorProcess) { logger.info("shut down conductor: conductor is not running"); return null; } logger.debug("closing admin and app web sockets\n"); assert(this._adminWs, "admin websocket is not connected"); await this._adminWs.client.close(); this._adminWs = undefined; if (this._appWs) { await this._appWs.client.close(); this._appWs = undefined; } logger.debug("shutting down conductor\n"); return new Promise((resolve) => { // I don't know why this is possibly undefined despite the initial guard assert(this.conductorProcess); this.conductorProcess.on("exit", (code) => { this.conductorProcess?.removeAllListeners(); this.conductorProcess?.stdout.removeAllListeners(); this.conductorProcess?.stderr.removeAllListeners(); this.conductorProcess = undefined; resolve(code); }); this.conductorProcess.kill("SIGINT"); }); } async connectAdminWs() { this._adminWs = await AdminWebsocket.connect({ url: this.adminApiUrl, wsClientOptions: { origin: _ALLOWED_ORIGIN }, defaultTimeout: this.timeout, }); logger.debug(`connected to Admin API @ ${this.adminApiUrl.href}\n`); } /** * Attach a web socket to the App API. * * @param request - Specify a port for the web socket (optional). * @returns The app interface port. */ async attachAppInterface(request) { request = request ?? { port: await getPort({ port: portNumbers(30000, 40000) }), allowed_origins: _ALLOWED_ORIGIN, }; logger.debug(`attaching App API to port ${request.port}\n`); const { port } = await this.adminWs().attachAppInterface(request); return port; } /** * Connect a web socket to the App API, * * @param token - A token to authenticate the connection. * @param port - The websocket port to connect to. * @returns An app websocket. */ async connectAppWs(token, port) { logger.debug(`connecting App WebSocket to port ${port}\n`); const appApiUrl = new URL(this.adminApiUrl.href); appApiUrl.port = port.toString(); const appWs = await AppWebsocket.connect({ token, url: appApiUrl, wsClientOptions: { origin: _ALLOWED_ORIGIN }, defaultTimeout: this.timeout, }); // set up automatic zome call signing const callZome = appWs.callZome.bind(appWs); appWs.callZome = async (req, timeout) => { let cellId; if ("role_name" in req) { assert(appWs.cachedAppInfo); cellId = appWs.getCellIdFromRoleName(req.role_name, appWs.cachedAppInfo); } else { cellId = req.cell_id; } if (!getSigningCredentials(cellId)) { await this.adminWs().authorizeSigningCredentials(cellId); } return callZome(req, timeout); }; return appWs; } /** * Get the path of the directory that contains all files and folders of the * conductor. * * @returns The conductor's temporary directory. */ getTmpDirectory() { assert(this.conductorDir); return this.conductorDir; } /** * Get all Admin API methods. * * @returns The Admin API web socket. */ adminWs() { assert(this._adminWs, "admin ws has not been connected"); return this._adminWs; } /** * Install an application into the conductor. * * @param appBundleSource - The bundle or path to the bundle. * @param options - {@link AppOptions} for the hApp bundle (optional). * @returns An agent app with cells and conductor handle. */ async installApp(appWithOptions) { const agent_key = appWithOptions.options?.agentPubKey ?? (await this.adminWs().generateAgentPubKey()); const installed_app_id = appWithOptions.options?.installedAppId ?? `app-${uuidv4()}`; const roles_settings = appWithOptions.options?.rolesSettings; const network_seed = appWithOptions.options?.networkSeed; const installAppRequest = { source: appWithOptions.appBundleSource, agent_key, roles_settings, installed_app_id, network_seed, }; logger.debug(`installing app with id ${installed_app_id} for agent ${encodeHashToBase64(agent_key)}`); return this.adminWs().installApp(installAppRequest); } /** * Install an app for multiple agents into the conductor. */ async installAgentsApps(options) { return Promise.all(options.agentsApps.map((appsForAgent) => this.installApp(appsForAgent))); } } /** * Run the `hc` command to delete all conductor data. * * @returns A promise that resolves when the command is complete. * * @public */ export const cleanAllConductors = async () => { const conductorProcess = spawn("hc", ["sandbox", "clean"]); return new Promise((resolve) => { conductorProcess.stdout.once("end", () => { logger.debug("sandbox conductors cleaned\n"); resolve(); }); }); };