UNPKG

@graphprotocol/graph-cli

Version:

CLI for building for and deploying to The Graph

385 lines (384 loc) 15.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const child_process_1 = require("child_process"); const http_1 = __importDefault(require("http")); const net_1 = __importDefault(require("net")); const path_1 = __importDefault(require("path")); const docker_compose_1 = __importDefault(require("docker-compose")); const gluegun_1 = require("gluegun"); const strip_ansi_1 = __importDefault(require("strip-ansi")); const tmp_promise_1 = __importDefault(require("tmp-promise")); const core_1 = require("@oclif/core"); const spinner_1 = require("../command-helpers/spinner"); // Clean up temporary files even when an uncaught exception occurs tmp_promise_1.default.setGracefulCleanup(); class LocalCommand extends core_1.Command { async run() { const { args: { 'local-command': testCommand }, flags: { 'compose-file': composeFileFlag, 'ethereum-logs': ethereumLogsFlag, 'node-image': nodeImage, 'node-logs': nodeLogsFlag, 'skip-wait-for-etherium': skipWaitForEthereumTypo, 'skip-wait-for-ethereum': skipWaitForEthereumGood, 'skip-wait-for-ipfs': skipWaitForIpfs, 'skip-wait-for-postgres': skipWaitForPostgres, 'standalone-node': standaloneNode, 'standalone-node-args': standaloneNodeArgs, timeout, }, } = await this.parse(LocalCommand); const skipWaitForEthereum = skipWaitForEthereumTypo || skipWaitForEthereumGood; // Obtain the Docker Compose file for services that the tests run against const composeFile = composeFileFlag || path_1.default.join(__dirname, '..', '..', 'resources', 'test', standaloneNode ? 'docker-compose-standalone-node.yml' : 'docker-compose.yml'); if (!gluegun_1.filesystem.exists(composeFile)) { this.error(`Docker Compose file "${composeFile}" not found`, { exit: 1 }); } // Create temporary directory to operate in const { path: tempdir } = await tmp_promise_1.default.dir({ prefix: 'graph-test', unsafeCleanup: true, }); try { await configureTestEnvironment(tempdir, composeFile, nodeImage); } catch (e) { this.exit(1); return; } // Bring up test environment try { await startTestEnvironment(tempdir); } catch (e) { this.error(e, { exit: 1 }); } // Wait for test environment to come up try { await waitForTestEnvironment({ skipWaitForEthereum, skipWaitForIpfs, skipWaitForPostgres, timeout, }); } catch (e) { await stopTestEnvironment(tempdir); this.exit(1); return; } // Bring up Graph Node separately, if a standalone node is used let nodeProcess; const nodeOutputChunks = []; if (standaloneNode) { try { nodeProcess = await startGraphNode(standaloneNode, standaloneNodeArgs, nodeOutputChunks); } catch (e) { await stopTestEnvironment(tempdir); let errorMessage = '\n'; errorMessage += ' Graph Node'; errorMessage += ' ----------'; errorMessage += indent(' ', Buffer.concat(nodeOutputChunks).toString('utf-8')); errorMessage += '\n'; this.error(errorMessage, { exit: 1 }); } } // Wait for Graph Node to come up try { await waitForGraphNode(timeout); } catch (e) { await stopTestEnvironment(tempdir); let errorMessage = '\n'; errorMessage += ' Graph Node'; errorMessage += ' ----------'; errorMessage += indent(' ', await collectGraphNodeLogs(tempdir, standaloneNode, nodeOutputChunks)); errorMessage += '\n'; this.error(errorMessage, { exit: 1 }); } // Run tests const result = await runTests(testCommand); // Bring down Graph Node, if a standalone node is used if (nodeProcess) { try { await stopGraphNode(nodeProcess); } catch (e) { // do nothing (the spinner already logs the problem) } } if (result.exitCode == 0) { this.log('✔ Tests passed'); } else { this.log('✖ Tests failed'); } // Capture logs const nodeLogs = nodeLogsFlag || result.exitCode !== 0 ? await collectGraphNodeLogs(tempdir, standaloneNode, nodeOutputChunks) : undefined; const ethereumLogs = ethereumLogsFlag ? await collectEthereumLogs(tempdir) : undefined; // Bring down the test environment try { await stopTestEnvironment(tempdir); } catch (e) { // do nothing (the spinner already logs the problem) } if (nodeLogs) { this.log(''); this.log(' Graph node'); this.log(' ----------'); this.log(''); this.log(indent(' ', nodeLogs)); } if (ethereumLogs) { this.log(''); this.log(' Ethereum'); this.log(' --------'); this.log(''); this.log(indent(' ', ethereumLogs)); } // Always print the test output this.log(''); this.log(' Output'); this.log(' ------'); this.log(''); this.log(indent(' ', result.output)); // Propagate the exit code from the test run this.exit(result.exitCode); } } LocalCommand.description = 'Runs local tests against a Graph Node environment (using Ganache by default).'; LocalCommand.args = { 'local-command': core_1.Args.string({ required: true, }), }; LocalCommand.flags = { help: core_1.Flags.help({ char: 'h', }), 'node-logs': core_1.Flags.boolean({ summary: 'Print the Graph Node logs.', }), 'ethereum-logs': core_1.Flags.boolean({ summary: 'Print the Ethereum logs.', }), 'compose-file': core_1.Flags.file({ summary: 'Custom Docker Compose file for additional services.', }), 'node-image': core_1.Flags.string({ summary: 'Custom Graph Node image to test against.', default: 'graphprotocol/graph-node:latest', }), 'standalone-node': core_1.Flags.string({ summary: 'Use a standalone Graph Node outside Docker Compose.', }), 'standalone-node-args': core_1.Flags.string({ summary: 'Custom arguments to be passed to the standalone Graph Node.', dependsOn: ['standalone-node'], }), 'skip-wait-for-ipfs': core_1.Flags.boolean({ summary: "Don't wait for IPFS to be up at localhost:15001", }), 'skip-wait-for-ethereum': core_1.Flags.boolean({ summary: "Don't wait for Ethereum to be up at localhost:18545", }), // TODO: Remove in next major release 'skip-wait-for-etherium': core_1.Flags.boolean({ summary: "Don't wait for Ethereum to be up at localhost:18545", deprecated: { message: 'Use --skip-wait-for-ethereum instead', }, }), 'skip-wait-for-postgres': core_1.Flags.boolean({ summary: "Don't wait for Postgres to be up at localhost:15432", }), timeout: core_1.Flags.integer({ summary: 'Time to wait for service containers in milliseconds.', default: 120000, }), }; exports.default = LocalCommand; /** * Indents all lines of a string */ const indent = (indentation, str) => str .split('\n') .map(s => `${indentation}${s}`) // Remove whitespace from empty lines .map(s => s.replace(/^\s+$/g, '')) .join('\n'); const configureTestEnvironment = async (tempdir, composeFile, nodeImage) => await (0, spinner_1.withSpinner)(`Configure test environment`, `Failed to configure test environment`, `Warnings configuring test environment`, async () => { // Temporary compose file const tempComposeFile = path_1.default.join(tempdir, 'compose', 'docker-compose.yml'); // Copy the compose file to the temporary directory gluegun_1.filesystem.copy(composeFile, tempComposeFile); // Substitute the graph-node image with the custom one, if appropriate if (nodeImage) { await gluegun_1.patching.replace(tempComposeFile, 'graphprotocol/graph-node:latest', nodeImage); } }); const waitFor = async (timeout, testFn) => { const deadline = Date.now() + timeout; let error = undefined; return new Promise((resolve, reject) => { const check = async () => { if (Date.now() > deadline) { reject(error); } else { try { const result = await testFn(); resolve(result); } catch (e) { error = e; setTimeout(check, 500); } } }; setTimeout(check, 0); }); }; const startTestEnvironment = async (tempdir) => await (0, spinner_1.withSpinner)(`Start test environment`, `Failed to start test environment`, `Warnings starting test environment`, async (_spinner) => { // Bring up the test environment await docker_compose_1.default.upAll({ cwd: path_1.default.join(tempdir, 'compose'), }); }); const waitForTestEnvironment = async ({ skipWaitForEthereum, skipWaitForIpfs, skipWaitForPostgres, timeout, }) => await (0, spinner_1.withSpinner)(`Wait for test environment`, `Failed to wait for test environment`, `Warnings waiting for test environment`, async (spinner) => { // Wait 10s for IPFS (if desired) if (skipWaitForIpfs) { (0, spinner_1.step)(spinner, 'Skip waiting for IPFS'); } else { await waitFor(timeout, async () => new Promise((resolve, reject) => { http_1.default .get('http://localhost:15001/api/v0/version', () => { resolve(); }) .on('error', e => { reject(new Error(`Could not connect to IPFS: ${e}`)); }); })); (0, spinner_1.step)(spinner, 'IPFS is up'); } // Wait 10s for Ethereum (if desired) if (skipWaitForEthereum) { (0, spinner_1.step)(spinner, 'Skip waiting for Ethereum'); } else { await waitFor(timeout, async () => new Promise((resolve, reject) => { http_1.default .get('http://localhost:18545', () => { resolve(); }) .on('error', e => { reject(new Error(`Could not connect to Ethereum: ${e}`)); }); })); (0, spinner_1.step)(spinner, 'Ethereum is up'); } // Wait 10s for Postgres (if desired) if (skipWaitForPostgres) { (0, spinner_1.step)(spinner, 'Skip waiting for Postgres'); } else { await waitFor(timeout, async () => new Promise((resolve, reject) => { try { const socket = net_1.default.connect(15432, 'localhost', () => resolve()); socket.on('error', e => reject(new Error(`Could not connect to Postgres: ${e}`))); socket.end(); } catch (e) { reject(new Error(`Could not connect to Postgres: ${e}`)); } })); (0, spinner_1.step)(spinner, 'Postgres is up'); } }); const stopTestEnvironment = async (tempdir) => await (0, spinner_1.withSpinner)(`Stop test environment`, `Failed to stop test environment`, `Warnings stopping test environment`, async () => { // Our containers do not respond quickly to the SIGTERM which `down` tries before timing out // and killing them, so speed things up by sending a SIGKILL right away. try { await docker_compose_1.default.kill({ cwd: path_1.default.join(tempdir, 'compose') }); } catch (e) { // Do nothing, we will just try to run 'down' // to bring down the environment } await docker_compose_1.default.down({ cwd: path_1.default.join(tempdir, 'compose') }); }); const startGraphNode = async (standaloneNode, standaloneNodeArgs, nodeOutputChunks) => await (0, spinner_1.withSpinner)(`Start Graph node`, `Failed to start Graph node`, `Warnings starting Graph node`, async (spinner) => { const defaultArgs = [ '--ipfs', 'localhost:15001', '--postgres-url', 'postgresql://graph:let-me-in@localhost:15432/graph', '--ethereum-rpc', 'test:http://localhost:18545', '--http-port', '18000', '--ws-port', '18001', '--admin-port', '18020', '--index-node-port', '18030', '--metrics-port', '18040', ]; const defaultEnv = { GRAPH_LOG: 'debug', GRAPH_MAX_API_VERSION: '0.0.5', }; const args = standaloneNodeArgs ? standaloneNodeArgs.split(' ') : defaultArgs; const env = { ...defaultEnv, ...process.env }; const nodeProcess = (0, child_process_1.spawn)(standaloneNode, args, { cwd: process.cwd(), env, }); (0, spinner_1.step)(spinner, 'Graph node:', String(nodeProcess.spawnargs.join(' '))); nodeProcess.stdout.on('data', data => nodeOutputChunks.push(Buffer.from(data))); nodeProcess.stderr.on('data', data => nodeOutputChunks.push(Buffer.from(data))); nodeProcess.on('error', e => { nodeOutputChunks.push(Buffer.from(String(e), 'utf-8')); }); // Return the node child process return nodeProcess; }); const waitForGraphNode = async (timeout) => await (0, spinner_1.withSpinner)(`Wait for Graph node`, `Failed to wait for Graph node`, `Warnings waiting for Graph node`, async () => { await waitFor(timeout, async () => new Promise((resolve, reject) => { http_1.default .get('http://localhost:18000', { timeout }, () => resolve()) .on('error', e => reject(e)); })); }); const stopGraphNode = async (nodeProcess) => await (0, spinner_1.withSpinner)(`Stop Graph node`, `Failed to stop Graph node`, `Warnings stopping Graph node`, async () => { nodeProcess.kill(9); }); const collectGraphNodeLogs = async (tempdir, standaloneNode, nodeOutputChunks) => { if (standaloneNode) { // Pull the logs from the captured output return (0, strip_ansi_1.default)(Buffer.concat(nodeOutputChunks).toString('utf-8')); } // Pull the logs from docker compose const logs = await docker_compose_1.default.logs('graph-node', { follow: false, cwd: path_1.default.join(tempdir, 'compose'), }); return (0, strip_ansi_1.default)(logs.out.trim()).replace(/graph-node_1 {2}\| /g, ''); }; const collectEthereumLogs = async (tempdir) => { const logs = await docker_compose_1.default.logs('ethereum', { follow: false, cwd: path_1.default.join(tempdir, 'compose'), }); return (0, strip_ansi_1.default)(logs.out.trim()).replace(/ethereum_1 {2}\| /g, ''); }; const runTests = async (testCommand) => await (0, spinner_1.withSpinner)(`Run tests`, `Failed to run tests`, `Warnings running tests`, async () => new Promise(resolve => { const output = []; const testProcess = (0, child_process_1.spawn)(String(testCommand), { shell: true }); testProcess.stdout.on('data', data => output.push(Buffer.from(data))); testProcess.stderr.on('data', data => output.push(Buffer.from(data))); testProcess.on('close', code => { resolve({ exitCode: code, output: Buffer.concat(output).toString('utf-8'), }); }); }));