@holochain/tryorama
Version:
Toolset to manage Holochain conductors and facilitate running test scenarios
325 lines (324 loc) • 12.2 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 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();
});
});
};