UNPKG

@graphprotocol/graph-cli

Version:

CLI for building for and deploying to The Graph

378 lines (377 loc) 15.1 kB
import { spawn } from 'node:child_process'; import http from 'node:http'; import net from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import compose from 'docker-compose'; import { filesystem, patching } from 'gluegun'; import stripAnsi from 'strip-ansi'; import tmp from 'tmp-promise'; import { Args, Command, Flags } from '@oclif/core'; import { step, withSpinner } from '../command-helpers/spinner.js'; // Clean up temporary files even when an uncaught exception occurs tmp.setGracefulCleanup(); export default class LocalCommand extends Command { static description = 'Runs local tests against a Graph Node environment (using Ganache by default).'; static args = { 'local-command': Args.string({ required: true, }), }; static flags = { help: Flags.help({ char: 'h', }), 'node-logs': Flags.boolean({ summary: 'Print the Graph Node logs.', }), 'ethereum-logs': Flags.boolean({ summary: 'Print the Ethereum logs.', }), 'compose-file': Flags.file({ summary: 'Custom Docker Compose file for additional services.', }), 'node-image': Flags.string({ summary: 'Custom Graph Node image to test against.', default: 'graphprotocol/graph-node:latest', }), 'standalone-node': Flags.string({ summary: 'Use a standalone Graph Node outside Docker Compose.', }), 'standalone-node-args': Flags.string({ summary: 'Custom arguments to be passed to the standalone Graph Node.', dependsOn: ['standalone-node'], }), 'skip-wait-for-ipfs': Flags.boolean({ summary: "Don't wait for IPFS to be up at localhost:15001", }), 'skip-wait-for-ethereum': Flags.boolean({ summary: "Don't wait for Ethereum to be up at localhost:18545", }), // TODO: Remove in next major release 'skip-wait-for-etherium': 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': Flags.boolean({ summary: "Don't wait for Postgres to be up at localhost:15432", }), timeout: Flags.integer({ summary: 'Time to wait for service containers in milliseconds.', default: 120_000, }), }; 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.join(fileURLToPath(import.meta.url), '..', '..', '..', 'resources', 'test', standaloneNode ? 'docker-compose-standalone-node.yml' : 'docker-compose.yml'); if (!filesystem.exists(composeFile)) { this.error(`Docker Compose file "${composeFile}" not found`, { exit: 1 }); } // Create temporary directory to operate in const { path: tempdir } = await tmp.dir({ prefix: 'graph-test', unsafeCleanup: true, }); try { await configureTestEnvironment(tempdir, composeFile, nodeImage); } catch (e) { this.exit(1); } // 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); } // 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); } } /** * 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 withSpinner(`Configure test environment`, `Failed to configure test environment`, `Warnings configuring test environment`, async () => { // Temporary compose file const tempComposeFile = path.join(tempdir, 'compose', 'docker-compose.yml'); // Copy the compose file to the temporary directory filesystem.copy(composeFile, tempComposeFile); // Substitute the graph-node image with the custom one, if appropriate if (nodeImage) { await 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 = testFn(); resolve(result); } catch (e) { error = e; setTimeout(check, 500); } } }; setTimeout(check, 0); }); }; const startTestEnvironment = async (tempdir) => await withSpinner(`Start test environment`, `Failed to start test environment`, `Warnings starting test environment`, async (_spinner) => { // Bring up the test environment await compose.upAll({ cwd: path.join(tempdir, 'compose'), }); }); const waitForTestEnvironment = async ({ skipWaitForEthereum, skipWaitForIpfs, skipWaitForPostgres, timeout, }) => await 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) { step(spinner, 'Skip waiting for IPFS'); } else { await waitFor(timeout, async () => new Promise((resolve, reject) => { http .get('http://localhost:15001/api/v0/version', () => { resolve(); }) .on('error', e => { reject(new Error(`Could not connect to IPFS: ${e}`)); }); })); step(spinner, 'IPFS is up'); } // Wait 10s for Ethereum (if desired) if (skipWaitForEthereum) { step(spinner, 'Skip waiting for Ethereum'); } else { await waitFor(timeout, async () => new Promise((resolve, reject) => { http .get('http://localhost:18545', () => { resolve(); }) .on('error', e => { reject(new Error(`Could not connect to Ethereum: ${e}`)); }); })); step(spinner, 'Ethereum is up'); } // Wait 10s for Postgres (if desired) if (skipWaitForPostgres) { step(spinner, 'Skip waiting for Postgres'); } else { await waitFor(timeout, async () => new Promise((resolve, reject) => { try { const socket = net.connect(15_432, '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}`)); } })); step(spinner, 'Postgres is up'); } }); const stopTestEnvironment = async (tempdir) => await 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 compose.kill({ cwd: path.join(tempdir, 'compose') }); } catch (e) { // Do nothing, we will just try to run 'down' // to bring down the environment } await compose.down({ cwd: path.join(tempdir, 'compose') }); }); const startGraphNode = async (standaloneNode, standaloneNodeArgs, nodeOutputChunks) => await 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 = spawn(standaloneNode, args, { cwd: process.cwd(), env, }); 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 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 .get('http://localhost:18000', { timeout }, () => resolve()) .on('error', e => reject(e)); })); }); const stopGraphNode = async (nodeProcess) => await 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 stripAnsi(Buffer.concat(nodeOutputChunks).toString('utf-8')); } // Pull the logs from docker compose const logs = await compose.logs('graph-node', { follow: false, cwd: path.join(tempdir, 'compose'), }); return stripAnsi(logs.out.trim()).replace(/graph-node_1 {2}\| /g, ''); }; const collectEthereumLogs = async (tempdir) => { const logs = await compose.logs('ethereum', { follow: false, cwd: path.join(tempdir, 'compose'), }); return stripAnsi(logs.out.trim()).replace(/ethereum_1 {2}\| /g, ''); }; const runTests = async (testCommand) => await withSpinner(`Run tests`, `Failed to run tests`, `Warnings running tests`, async () => new Promise(resolve => { const output = []; const testProcess = 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'), }); }); }));