@holochain/tryorama
Version:
Toolset to manage Holochain conductors and facilitate running test scenarios
338 lines (337 loc) • 12.8 kB
JavaScript
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();
});
});
};