UNPKG

@holochain/tryorama

Version:

Toolset to manage Holochain conductors and facilitate running test scenarios

855 lines (854 loc) 30 kB
import { encodeHashToBase64, generateSigningKeyPair, getSigningCredentials, randomCapSecret, setSigningCredentials, signZomeCall, } from "@holochain/client"; import getPort, { portNumbers } from "get-port"; import assert from "node:assert"; import { URL } from "node:url"; import { v4 as uuidv4 } from "uuid"; import { _ALLOWED_ORIGIN } from "../../common.js"; import { makeLogger } from "../../logger.js"; import { AppApiResponseOk, TRYCP_SUCCESS_RESPONSE, } from "../types.js"; import { deserializeZomeResponsePayload } from "../util.js"; const logger = makeLogger("TryCP conductor"); const HOLO_SIGNALING_SERVER = new URL("wss://sbd-0.main.infra.holo.host"); const HOLO_BOOTSTRAP_SERVER = new URL("https://devnet-bootstrap.holo.host"); const BOOTSTRAP_SERVER_PLACEHOLDER = "<bootstrap_server_url>"; const SIGNALING_SERVER_PLACEHOLDER = "<signaling_server_url>"; const DPKI_CONFIG_PLACEHOLDER = "<dpki_config>"; /** * The default partial config for a TryCP conductor. * * @public */ export const DEFAULT_PARTIAL_PLAYER_CONFIG = `signing_service_uri: ~ encryption_service_uri: ~ decryption_service_uri: ~ device_seed_lair_tag: null danger_generate_throwaway_device_seed: false ${DPKI_CONFIG_PLACEHOLDER} network: network_type: "quic_bootstrap" bootstrap_service: ${BOOTSTRAP_SERVER_PLACEHOLDER} transport_pool: - type: webrtc signal_url: ${SIGNALING_SERVER_PLACEHOLDER}`; const getPartialConfig = (noDpki = false, dpkiNetworkSeed = "deepkey-test", bootstrapServerUrl, signalingServerUrl) => { const dpkiConfig = noDpki ? `dpki: dna_path: ~ network_seed: ~ no_dpki: true` : `dpki: dna_path: ~ network_seed: ${dpkiNetworkSeed} allow_throwaway_random_dpki_agent_key: true no_dpki: false`; const partialConfig = DEFAULT_PARTIAL_PLAYER_CONFIG.replace(BOOTSTRAP_SERVER_PLACEHOLDER, (bootstrapServerUrl ?? HOLO_BOOTSTRAP_SERVER).href) .replace(SIGNALING_SERVER_PLACEHOLDER, (signalingServerUrl ?? HOLO_SIGNALING_SERVER).href) .replace(DPKI_CONFIG_PLACEHOLDER, dpkiConfig); return partialConfig; }; /** * The function to create a TryCP Conductor. By default configures and starts * it. * * @param tryCpClient - The client connection to the TryCP server on which to * create the conductor. * @param options - Options to configure how the conductor will be started and run. * @returns A conductor instance. * * @public */ export const createTryCpConductor = async (tryCpClient, options) => { const conductor = new TryCpConductor(tryCpClient, options?.id); if (options?.startup !== false) { // configure and startup conductor by default await conductor.configure(options?.partialConfig, true, undefined); await conductor.startUp({ logLevel: options?.logLevel }); } return conductor; }; /** * A class to manage a conductor running on a TryCP server. * * @public */ export class TryCpConductor { id; tryCpClient; constructor(tryCpClient, id) { this.tryCpClient = tryCpClient; this.id = id || `conductor-${uuidv4()}`; } static defaultPartialConfig() { return getPartialConfig(true); } /** * Create conductor configuration. * * @param partialConfig - The configuration to add to the default configuration. * @param noDpki - Disable the DPKI service on this conductor. * @param dpkiNetworkSeed - Set DPKI network seed. * @returns An empty success response. */ async configure(partialConfig, noDpki = false, dpkiNetworkSeed = "deepkey-test") { if (!partialConfig) { partialConfig = getPartialConfig(noDpki, dpkiNetworkSeed, this.tryCpClient.bootstrapServerUrl, this.tryCpClient.signalingServerUrl); } const response = await this.tryCpClient.call({ type: "configure_player", id: this.id, partial_config: partialConfig, }); assert(response === TRYCP_SUCCESS_RESPONSE); return response; } /** * Start a configured conductor. * * @param options - Log level of the conductor. Defaults to "info". * @returns An empty success response. * * @public */ async startUp(options) { const response = await this.tryCpClient.call({ type: "startup", id: this.id, log_level: options?.logLevel, }); assert(response === TRYCP_SUCCESS_RESPONSE); return response; } /** * Shut down the conductor. * * @returns An empty success response. * * @public */ async shutDown() { const response = await this.tryCpClient.call({ type: "shutdown", id: this.id, }); assert(response === TRYCP_SUCCESS_RESPONSE); return response; } /** * Disconnect the TryCP client from the TryCP server. * * @returns The web socket close code. */ async disconnectClient() { const response = await this.tryCpClient.close(); assert(response === 1000); return response; } /** * Download a DNA from a URL to the server's file system. * * @returns The relative path to the downloaded DNA file. */ async downloadDna(url) { const response = await this.tryCpClient.call({ type: "download_dna", url: url.href, }); assert(typeof response === "string"); return response; } /** * Upload a DNA file from the local file system to the server. * * @param dnaContent - The DNA as binary content. * @returns The relative path to the saved DNA file. */ async saveDna(dnaContent) { const response = await this.tryCpClient.call({ type: "save_dna", id: "./entry.dna", content: dnaContent, }); assert(typeof response === "string"); return response; } /** * Connect a web socket to the App API. * * @param port - The port to attach the app interface to. * @returns An empty success response. */ async connectAppInterface(token, port) { const response = await this.tryCpClient.call({ type: "connect_app_interface", token, port, }); assert(response === TRYCP_SUCCESS_RESPONSE); return response; } /** * Disconnect a web socket from the App API. * * @param port - The port of the app interface to disconnect. * @returns An empty success response. */ async disconnectAppInterface(port) { const response = await this.tryCpClient.call({ type: "disconnect_app_interface", port, }); assert(response === TRYCP_SUCCESS_RESPONSE); return response; } async downloadLogs() { const response = await this.tryCpClient.call({ type: "download_logs", id: this.id, }); assert(response !== TRYCP_SUCCESS_RESPONSE); assert(typeof response === "object"); assert("type" in response); assert(response.type === "download_logs"); return response.data; } /** * Attach a signal handler. * * @param signalHandler - The signal handler to register. * @param port - The port of the app interface. */ on(port, signalHandler) { this.tryCpClient.setSignalHandler(port, signalHandler); } /** * Detach the registered signal handler. */ off(port) { this.tryCpClient.unsetSignalHandler(port); } /** * Send a call to the Admin API. * * @param message - The call to send to the Admin API. * @returns The response of the call. * * @internal */ async callAdminApi(message) { const response = await this.tryCpClient.call({ type: "call_admin_interface", id: this.id, message, }); assert(response !== TRYCP_SUCCESS_RESPONSE); assert(typeof response !== "string"); return response; } /** * Get all * {@link https://github.com/holochain/holochain-client-js/blob/develop/docs/API_adminwebsocket.md | Admin API methods} * of the Holochain client. * * @returns The Admin API web socket. */ adminWs() { /** * Upload and register a DNA file. * * @param request - {@link RegisterDnaRequest} & {@link DnaSource} * @returns The registered DNA's {@link HoloHash}. */ const registerDna = async (request) => { const response = await this.callAdminApi({ type: "register_dna", value: request, }); assert(response.type === "dna_registered"); return response.value; }; /** * Get a DNA definition. * * @param dnaHash - Hash of DNA to query. * @returns The {@link DnaDefinition}. */ const getDnaDefinition = async (dnaHash) => { const response = await this.callAdminApi({ type: "get_dna_definition", value: dnaHash, }); assert(response.type === "dna_definition_returned"); return response.value; }; /** * Get set of compatible DNA hashes of a DNA. * * @param dnaHash - Hash of DNA to query. * @returns The {@link GetCompatibleCellsResponse}. */ const getCompatibleCells = async (dnaHash) => { const response = await this.callAdminApi({ type: "get_compatible_cells", value: dnaHash, }); assert(response.type === "compatible_cells"); return response.value; }; /** * Grant a capability for a zome call. * * @param request - Public key to grant and cell, zome and functions for * which to grant the capability. */ const grantZomeCallCapability = async (request) => { const response = await this.callAdminApi({ type: "grant_zome_call_capability", value: request, }); assert(response.type === "zome_call_capability_granted"); }; /** * Generate a new agent pub key. * * @returns The generated {@link AgentPubKey}. */ const generateAgentPubKey = async () => { const response = await this.callAdminApi({ type: "generate_agent_pub_key", }); assert(response.type === "agent_pub_key_generated"); return response.value; }; /** * Revoke an agent key for an app in DPKI. * * @param data - {@link RevokeAgentKeyRequest} * @returns A list of errors of the cells where deletion was unsuccessful. */ const revokeAgentKey = async (request) => { const response = await this.callAdminApi({ type: "revoke_agent_key", value: request, }); assert(response.type === "agent_key_revoked"); return response.value; }; /** * Install an app. * * @param data - {@link InstallAppBundleRequest}. * @returns {@link @holochain/client#InstalledAppInfo}. */ const installApp = async (request) => { const response = await this.callAdminApi({ type: "install_app", value: request, }); assert(response.type === "app_installed"); return response.value; }; /** * Enable an installed hApp. * * @param request -{@link EnableAppRequest}. * @returns {@link @holochain/client#EnableAppResonse}. */ const enableApp = async (request) => { const response = await this.callAdminApi({ type: "enable_app", value: request, }); assert(response.type === "app_enabled"); return response.value; }; /** * Disable an installed hApp. * * @param request -{@link DisableAppRequest}. * @returns An empty success response. */ const disableApp = async (request) => { const response = await this.callAdminApi({ type: "disable_app", value: request, }); assert(response.type === "app_disabled"); return response.value; }; /** * Start an installed hApp. * * @param request -{@link StartAppRequest}. * @returns {@link @holochain/client#StartAppResponse}. */ const startApp = async (request) => { const response = await this.callAdminApi({ type: "start_app", value: request, }); assert(response.type === "app_started"); return response.value; }; /** * Uninstall an installed hApp. * * @param request - {@link UninstallAppRequest}. * @returns An empty success response. */ const uninstallApp = async (request) => { const response = await this.callAdminApi({ type: "uninstall_app", value: request, }); assert(response.type === "app_uninstalled"); return response.value; }; /** * Update coordinator zomes of an installed DNA. * * @param request - {@link UninstallAppRequest}. * @returns An empty success response. */ const updateCoordinators = async (request) => { const response = await this.callAdminApi({ type: "update_coordinators", value: request, }); assert(response.type === "coordinators_updated"); return response.value; }; /** * List all installed hApps. * * @param request - Filter by hApp status (optional). * @returns A list of all installed hApps. */ const listApps = async (request) => { const response = await this.callAdminApi({ type: "list_apps", value: request, }); assert(response.type === "apps_listed"); return response.value; }; /** * List all installed Cell ids. * * @returns A list of all installed {@link Cell} ids. */ const listCellIds = async () => { const response = await this.callAdminApi({ type: "list_cell_ids", }); assert(response.type === "cell_ids_listed"); return response.value; }; /** * List all installed DNAs. * * @returns A list of all installed DNAs' role ids. */ const listDnas = async () => { const response = await this.callAdminApi({ type: "list_dnas" }); assert(response.type === "dnas_listed"); return response.value; }; /** * Attach an App interface to the conductor. * * @param request - The port to attach to. * @returns The port the App interface was attached to. */ const attachAppInterface = async (request) => { request = { allowed_origins: request?.allowed_origins ?? _ALLOWED_ORIGIN, port: request?.port ?? (await getPort({ port: portNumbers(30000, 40000) })), }; const response = await this.callAdminApi({ type: "attach_app_interface", value: request, }); assert(response.type === "app_interface_attached"); return { port: response.value.port, }; }; /** * List all App interfaces. * * @returns A list of all attached App interfaces. */ const listAppInterfaces = async () => { const response = await this.callAdminApi({ type: "list_app_interfaces", }); assert(response.type === "app_interfaces_listed"); return response.value; }; /** * Get agent infos, optionally of a particular cell. * * @param req - The cell id to get agent infos of (optional). * @returns The agent infos. */ const agentInfo = async (req) => { const response = await this.callAdminApi({ type: "agent_info", value: { cell_id: req.cell_id || null, }, }); assert(response.type === "agent_info"); return response.value; }; /** * Add agents to a conductor. * * @param request - The agents to add to the conductor. */ const addAgentInfo = async (request) => { const response = await this.callAdminApi({ type: "add_agent_info", value: request, }); assert(response.type === "agent_info_added"); }; /** * Delete a disabled clone cell. * * @param request - The app id and clone cell id to delete. */ const deleteCloneCell = async (request) => { const response = await this.callAdminApi({ type: "delete_clone_cell", value: request, }); assert(response.type === "clone_cell_deleted"); }; /** * Request a dump of the cell's state. * * @param request - The cell id for which state should be dumped. * @returns The cell's state as JSON. */ const dumpState = async (request) => { const response = await this.callAdminApi({ type: "dump_state", value: request, }); assert("value" in response); assert(response.type === "state_dumped"); const stateDump = JSON.parse(response.value.replace(/\\n/g, "")); return stateDump; }; /** * Request a full state dump of the cell's source chain. * * @param request - {@link DumpFullStateRequest} * @returns {@link @holochain/client#FullStateDump}. */ const dumpFullState = async (request) => { const response = await this.callAdminApi({ type: "dump_full_state", value: request, }); assert(response.type === "full_state_dumped"); return response.value; }; /** * Request a network stats dump of the conductor. * * @param request - {@link DumpNetworkStatsRequest} * @returns {@link @holochain/client#DumpNetworkStatsResponse}. */ const dumpNetworkStats = async (request) => { const response = await this.callAdminApi({ type: "dump_network_stats", value: request, }); assert(response.type === "network_stats_dumped"); return response.value; }; /** * Request storage info from the conductor. * * @param request - {@link StorageInfoRequest} * @returns {@link @holochain/client#StorageInfoResponse}. */ const storageInfo = async (request) => { const response = await this.callAdminApi({ type: "storage_info", value: request, }); assert(response.type === "storage_info"); return response.value; }; const issueAppAuthenticationToken = async (request) => { const response = await this.callAdminApi({ type: "issue_app_authentication_token", value: request, }); assert(response.type === "app_authentication_token_issued"); return response.value; }; /** * Grant a capability for signing zome calls. * * @param cellId - The cell to grant the capability for. * @param functions - The zome functions to grant the capability for. * @param signingKey - The assignee of the capability. * @returns The cap secret of the created capability. */ const grantSigningKey = async (cellId, functions, signingKey) => { const capSecret = await randomCapSecret(); await grantZomeCallCapability({ cell_id: cellId, cap_grant: { tag: "zome-call-signing-key", functions, access: { type: "assigned", value: { secret: capSecret, assignees: [signingKey], }, }, }, }); return capSecret; }; /** * Generate and authorize a new key pair for signing zome calls. * * @param cellId - The cell id to create the capability grant for. * @param functions - Zomes and functions to authorize the signing key for. */ const authorizeSigningCredentials = async (cellId, functions) => { const [keyPair, signingKey] = await generateSigningKeyPair(); const capSecret = await grantSigningKey(cellId, functions || { type: "all" }, signingKey); setSigningCredentials(cellId, { capSecret, keyPair, signingKey }); }; return { addAgentInfo, agentInfo, attachAppInterface, authorizeSigningCredentials, deleteCloneCell, disableApp, dumpFullState, dumpNetworkStats, dumpState, enableApp, generateAgentPubKey, getCompatibleCells, getDnaDefinition, grantSigningKey, grantZomeCallCapability, installApp, listAppInterfaces, listApps, listCellIds, listDnas, registerDna, revokeAgentKey, startApp, storageInfo, uninstallApp, updateCoordinators, issueAppAuthenticationToken, }; } /** * Call to the conductor's App API. */ async callAppApi(port, message) { const response = await this.tryCpClient.call({ type: "call_app_interface", port, message, }); assert(response !== TRYCP_SUCCESS_RESPONSE); assert(typeof response !== "string"); return response; } /** * Get all * {@link https://github.com/holochain/holochain-client-js/blob/develop/docs/API_appwebsocket.md | App API methods} * of the Holochain client. * * @returns The App API web socket. */ async connectAppWs(_token, port) { /** * Request info of the installed hApp. * * @returns The app info. */ const appInfo = async () => { const response = await this.callAppApi(port, { type: "app_info", }); assert(response.type === "app_info"); return response.value; }; /** * Provide membrane proofs for the app. * * @param memproofs - A map of {@link MembraneProof}s. */ const provideMemproofs = async (request) => { const response = await this.callAppApi(port, { type: "provide_memproofs", value: request, }); assert(response.type === AppApiResponseOk); }; /** * Enablie an app only if the app is in the `AppStatus::Disabled(DisabledAppReason::NotStartedAfterProvidingMemproofs)` * state. Attempting to enable the app from other states (other than Running) will fail. */ const enableApp = async () => { const response = await this.callAppApi(port, { type: "enable_app" }); assert(response.type === AppApiResponseOk); }; /** * Make a zome call to a cell in the conductor. * * @param request - {@link CallZomeRequest}. * @returns The result of the zome call. */ const callZome = async (request) => { // authorize signing credentials if (!getSigningCredentials(request.cell_id)) { await this.adminWs().authorizeSigningCredentials(request.cell_id); } // sign zome call const signingCredentials = getSigningCredentials(request.cell_id); if (!signingCredentials) { throw new Error(`cannot sign zome call: no signing credentials have been authorized for cell ${request.cell_id}`); } const signedRequest = await signZomeCall(request); const response = await this.callAppApi(port, { type: "call_zome", value: signedRequest, }); assert("value" in response); assert(response.value); assert("BYTES_PER_ELEMENT" in response.value); const deserializedPayload = deserializeZomeResponsePayload(response.value); return deserializedPayload; }; /** * Create a clone cell of an existing DNA. * * @param request - Clone cell params. * @returns The clone id and cell id of the created clone cell. */ const createCloneCell = async (request) => { const response = await this.callAppApi(port, { type: "create_clone_cell", value: request, }); assert(response.type === "clone_cell_created"); return response.value; }; /** * Enable a disabled clone cell. * * @param request - The clone id or cell id of the clone cell to be * enabled. * @returns The enabled clone cell's clone id and cell id. */ const enableCloneCell = async (request) => { const response = await this.callAppApi(port, { type: "enable_clone_cell", value: request, }); assert(response.type === "clone_cell_enabled"); return response.value; }; /** * Archive an existing clone cell. * * @param request - The hApp id to query. * @returns An empty success response. */ const disableCloneCell = async (request) => { const response = await this.callAppApi(port, { type: "disable_clone_cell", value: request, }); assert(response.type === "clone_cell_disabled"); return response.value; }; /** * Request network info. * * @param request - {@link NetworkInfoRequest}. * @returns {@link NetworkInfoResponse}. */ const networkInfo = async (request) => { const response = await this.callAppApi(port, { type: "network_info", value: request, }); assert(response.type === "network_info"); return response.value; }; return { appInfo, callZome, enableApp, createCloneCell, enableCloneCell, disableCloneCell, networkInfo, provideMemproofs, }; } /** * Install a hApp bundle into the conductor. * * @param appBundleSource - The bundle or path to the bundle. * @param options - {@link AppOptions} for the hApp bundle (optional). * @returns The installed app info. */ async installApp(appBundleSource, options) { const agent_key = options?.agentPubKey ?? (await this.adminWs().generateAgentPubKey()); const roles_settings = options?.rolesSettings; const installed_app_id = options?.installedAppId ?? `app-${uuidv4()}`; const network_seed = options?.networkSeed; const installAppRequest = { source: appBundleSource, agent_key, roles_settings, installed_app_id, network_seed, }; return this.adminWs().installApp(installAppRequest); } /** * Install a hApp bundle into the conductor. * * @param options - Apps to install for each agent, with agent pub keys etc. * @returns The installed app infos. */ async installAgentsApps(options) { return Promise.all(options.agentsApps.map(async (appForAgent) => { const agent_key = appForAgent.agentPubKey ?? (await this.adminWs().generateAgentPubKey()); const roles_settings = appForAgent.rolesSettings; const installed_app_id = options.installedAppId ?? `app-${uuidv4()}`; const network_seed = options.networkSeed; const installAppRequest = { source: appForAgent.app, 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); })); } }