UNPKG

@holochain/tryorama

Version:

Toolset to manage Holochain conductors and facilitate running test scenarios

338 lines (337 loc) 12.8 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 defaultLogger = 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\n"; /** * 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", "label", ]); const conductor = await Conductor.create(signalingServerUrl, createConductorOptions); const networkConfig = pick(options, [ "initiateIntervalMs", "minInitiateIntervalMs", "initiateJitterMs", "roundTimeoutMs", "transportTimeoutS", "targetArcFactor", ]); conductor.setNetworkConfig(networkConfig, signalingServerUrl); 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; _logger; timeout; constructor(label, timeout) { this.conductorProcess = undefined; this.conductorDir = undefined; this.adminApiUrl = new URL(HOST_URL.href); this._adminWs = undefined; this._appWs = undefined; this._logger = makeLogger(label); 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("quic"); args.push(signalingServerUrl.href); defaultLogger.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?.label, options?.timeout); return new Promise((resolve, reject) => { createConductorProcess.stdout.on("data", (data) => { defaultLogger.debug(`creating conductor config\n${data.toString()}`); const tmpDirMatches = [ ...data.toString().matchAll(/DataRootPath\("(.*?)"\)/g), ]; if (tmpDirMatches.length) { conductor.conductorDir = tmpDirMatches[0][1]; } }); createConductorProcess.stdout.on("end", () => { resolve(conductor); }); createConductorProcess.stderr.on("data", (err) => { defaultLogger.error(`error when creating conductor config: ${err}\n`); reject(err); }); }); } setNetworkConfig(createConductorOptions, signalingServerUrl) { 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; } conductorConfigYaml.network.target_arc_factor = createConductorOptions.targetArcFactor ?? 1; if (signalingServerUrl) { conductorConfigYaml.network.signal_url = signalingServerUrl.href; } assert("advanced" in conductorConfigYaml.network); conductorConfigYaml.network.advanced = { k2Gossip: { initiateIntervalMs: createConductorOptions.initiateIntervalMs ?? 3_000, minInitiateIntervalMs: createConductorOptions.minInitiateIntervalMs ?? 3_000, initiateJitterMs: createConductorOptions.initiateJitterMs ?? 1_000, roundTimeoutMs: createConductorOptions.roundTimeoutMs ?? 5_000, }, irohTransport: { relayAllowPlainText: true, }, }; const yamlDump = yaml.dump(conductorConfigYaml); this._logger.debug("Updated conductor config:"); this._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) { this._logger.error("error starting conductor: conductor is already running\n"); return; } const runConductorProcess = spawn("holochain", [ "--piped", "-c", `${this.conductorDir}/${CONDUCTOR_CONFIG}`, ]); runConductorProcess.stdin.write(LAIR_PASSWORD); runConductorProcess.stdin.end(); this.conductorProcess = runConductorProcess; // Wait for conductor startup to complete and admin ws to be available let adminPortLogged = false; const adminPortPromise = new Promise((resolve) => { runConductorProcess.stdout.on("data", (data) => { this._logger.info(data.toString()); if (!adminPortLogged) { // Once we have an admin port, the conductor is launched and usable. const adminPort = data.toString().match(/###ADMIN_PORT:(\d*)###/); if (adminPort !== null) { adminPortLogged = true; resolve(adminPort[1]); } } }); runConductorProcess.stderr.on("data", (data) => { this._logger.error(data.toString()); }); }); this.adminApiUrl.port = await adminPortPromise; // Connect to admin ws await this.connectAdminWs(); } /** * Close Admin and App API connections and kill the conductor process. */ async shutDown() { if (!this.conductorProcess) { this._logger.info("shut down conductor: conductor is not running"); return null; } this._logger.debug("closing admin and app web sockets\n"); if (this._adminWs) { await this._adminWs.client.close(); this._adminWs = undefined; } if (this._appWs) { await this._appWs.client.close(); this._appWs = undefined; } this._logger.debug("shutting down conductor\n"); return new Promise((resolve) => { assert(this.conductorProcess); // Kill process after timeout if terminating didn't succeed. const timer = setTimeout(() => { this.conductorProcess?.kill("SIGKILL"); }, 5_000); this.conductorProcess.addListener("close", (code) => { clearTimeout(timer); this.conductorProcess?.removeAllListeners(); this.conductorProcess?.stdout.removeAllListeners(); this.conductorProcess?.stderr.removeAllListeners(); this.conductorProcess = undefined; resolve(code); }); this.conductorProcess.kill("SIGTERM"); }); } async connectAdminWs() { this._adminWs = await AdminWebsocket.connect({ url: this.adminApiUrl, wsClientOptions: { origin: _ALLOWED_ORIGIN }, defaultTimeout: this.timeout, }); this._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, }; this._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) { this._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, }; this._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", () => { defaultLogger.debug("sandbox conductors cleaned\n"); resolve(); }); }); };