UNPKG

@graphprotocol/graph-cli

Version:

CLI for building for and deploying to The Graph

316 lines (315 loc) • 12.8 kB
import { exec, spawn } from 'node:child_process'; import events from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { filesystem, patching, print, system } from 'gluegun'; import yaml from 'js-yaml'; import semver from 'semver'; import { Args, Command, Flags } from '@oclif/core'; import { GRAPH_CLI_SHARED_HEADERS } from '../constants.js'; export default class TestCommand extends Command { static description = 'Runs rust binary for subgraph testing.'; static args = { datasource: Args.string(), }; static flags = { help: Flags.help({ char: 'h', }), coverage: Flags.boolean({ summary: 'Run the tests in coverage mode.', char: 'c', }), docker: Flags.boolean({ summary: 'Run the tests in a docker container (Note: Please execute from the root folder of the subgraph).', char: 'd', }), force: Flags.boolean({ summary: 'Binary - overwrites folder + file when downloading. Docker - rebuilds the docker image.', char: 'f', }), logs: Flags.boolean({ summary: 'Logs to the console information about the OS, CPU model and download url (debugging purposes).', char: 'l', }), recompile: Flags.boolean({ summary: 'Force-recompile tests.', char: 'r', }), version: Flags.string({ summary: 'Choose the version of the rust binary that you want to be downloaded/used.', char: 'v', }), }; async run() { const { args: { datasource }, flags: { coverage, docker, force, logs, recompile, version }, } = await this.parse(TestCommand); let testsDir = './tests'; // Check if matchstick.yaml config exists if (filesystem.exists('matchstick.yaml')) { try { // Load the config const config = await yaml.load(filesystem.read('matchstick.yaml', 'utf8')); // Check if matchstick.yaml and testsFolder not null if (config?.testsFolder) { // assign test folder from matchstick.yaml if present testsDir = config.testsFolder; } } catch (error) { this.error(`A problem occurred while reading "matchstick.yaml":\n${error.message}`, { exit: 1, }); } } const cachePath = path.resolve(testsDir, '.latest.json'); const opts = { testsDir, cachePath, coverage, docker, force, logs, recompile, version, latestVersion: getLatestVersionFromCache(cachePath), }; // Fetch the latest version tag if version is not specified with -v/--version or if the version is not cached if (opts.force || (!opts.version && !opts.latestVersion)) { this.log('Fetching latest version tag...'); const result = await fetch('https://api.github.com/repos/LimeChain/matchstick/releases/latest', { headers: { ...GRAPH_CLI_SHARED_HEADERS, }, }); const json = await result.json(); opts.latestVersion = json.tag_name; filesystem.file(opts.cachePath, { content: { version: json.tag_name, timestamp: Date.now(), }, }); } if (opts.docker) { await runDocker.bind(this)(datasource, opts); } else { await runBinary.bind(this)(datasource, opts); } } } function getLatestVersionFromCache(cachePath) { if (filesystem.exists(cachePath) == 'file') { const cached = filesystem.read(cachePath, 'json'); // Get the cache age in days const cacheAge = (Date.now() - cached.timestamp) / (1000 * 60 * 60 * 24); // If cache age is less than 1 day, use the cached version if (cacheAge < 1) { return cached.version; } } return null; } async function runBinary(datasource, opts) { const coverageOpt = opts.coverage; const logsOpt = opts.logs; const versionOpt = opts.version; const latestVersion = opts.latestVersion; const recompileOpt = opts.recompile; let binPath = ''; try { const platform = await getPlatform.bind(this)(versionOpt || latestVersion, logsOpt); const url = `https://github.com/LimeChain/matchstick/releases/download/${versionOpt || latestVersion}/${platform}`; const binDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'node_modules', '.bin'); binPath = path.join(binDir, `matchstick-${platform}`); if (logsOpt) { this.log(`Download link: ${url}`); this.log(`Binary path: ${binPath}`); } if (!fs.existsSync(binPath)) { this.log(`Downloading matchstick binary: ${url}`); await fs.promises.mkdir(binDir, { recursive: true }); const response = await fetch(url); if (!response.ok) throw new Error(`Status: ${response.statusText}`); if (!response.body) throw new Error('No response body received'); const fileStream = fs.createWriteStream(binPath); await pipeline(response.body, fileStream); await fs.promises.chmod(binPath, '755'); } } catch (e) { this.error(`Failed to get matchstick binary: ${e.message}\nConsider using -d flag to run it in Docker instead:\n graph test -d`, { exit: 1 }); } const args = []; if (coverageOpt) args.push('-c'); if (recompileOpt) args.push('-r'); if (datasource) args.push(datasource); const child = spawn(binPath, args, { stdio: 'inherit' }); const [code] = await events.once(child, 'exit'); if (code !== 0) { this.error('Matchstick exited with an error', { exit: 1 }); } } async function getPlatform(matchstickVersion, logsOpt) { const type = os.type(); const arch = os.arch(); const cpuCore = os.cpus()[0]; const isAppleSilicon = arch === 'arm64' && /Apple (M[0-9]|processor)/.test(cpuCore.model); const linuxInfo = type === 'Linux' ? await getLinuxInfo.bind(this)() : {}; const linuxDistro = linuxInfo.name; const release = linuxInfo.version || os.release(); const majorVersion = parseInt(linuxInfo.version || '', 10) || semver.major(release); if (logsOpt) { this.log(`OS type: ${linuxDistro || type}\nOS arch: ${arch}\nOS release: ${release}\nOS major version: ${majorVersion}\nCPU model: ${cpuCore.model}`); } if (arch === 'x64' || isAppleSilicon) { if (semver.gt(matchstickVersion, '0.5.4')) { if (type === 'Darwin') { if (isAppleSilicon) { return 'binary-macos-12-m1'; } return 'binary-macos-12'; } if (type === 'Linux' && (majorVersion === 22 || majorVersion === 24)) { return 'binary-linux-22'; } } else { if (type === 'Darwin') { if (majorVersion === 18 || majorVersion === 19) { return 'binary-macos-10.15'; } if (isAppleSilicon) { return 'binary-macos-11-m1'; } return 'binary-macos-11'; } if (type === 'Linux') { return majorVersion === 18 ? 'binary-linux-18' : majorVersion === 22 ? 'binary-linux-22' : 'binary-linux-20'; } } } throw new Error(`Unsupported platform: ${type} ${arch} ${majorVersion}`); } async function getLinuxInfo() { try { const result = await system.run("cat /etc/*-release | grep -E '(^VERSION|^NAME)='", { trim: true, }); const infoArray = result .replace(/['"]+/g, '') .split('\n') .map(p => p.split('=')); const linuxInfo = {}; for (const val of infoArray) { linuxInfo[val[0].toLowerCase()] = val[1]; } return linuxInfo; } catch (error) { this.error(`Error fetching the Linux version:\n${error}`, { exit: 1 }); } } async function runDocker(datasource, opts) { const coverageOpt = opts.coverage; const forceOpt = opts.force; const versionOpt = opts.version; const latestVersion = opts.latestVersion; const recompileOpt = opts.recompile; // Get current working directory const current_folder = filesystem.cwd(); // Declate dockerfilePath with default location const dockerfilePath = path.join(opts.testsDir, '.docker/Dockerfile'); // Check if the Dockerfil already exists const dockerfileExists = filesystem.exists(dockerfilePath); // Generate the Dockerfile only if it doesn't exists, // version flag and/or force flag is passed. if (!dockerfileExists || versionOpt || forceOpt) { await dockerfile.bind(this)(dockerfilePath, versionOpt, latestVersion); } // Run a command to check if matchstick image already exists exec('docker images -q matchstick', (_error, stdout, _stderr) => { // Collect all(if any) flags and options that have to be passed to the matchstick binary let testArgs = ''; if (coverageOpt) testArgs = testArgs + ' -c'; if (recompileOpt) testArgs = testArgs + ' -r'; if (datasource) testArgs = testArgs + ' ' + datasource; // Build the `docker run` command options and flags const dockerRunOpts = [ 'run', '-it', '--rm', '--mount', `type=bind,source=${current_folder},target=/matchstick`, ]; if (testArgs !== '') { dockerRunOpts.push('-e'); dockerRunOpts.push(`ARGS=${testArgs.trim()}`); } dockerRunOpts.push('matchstick'); // If a matchstick image does not exists, the command returns an empty string, // else it'll return the image ID. Skip `docker build` if an image already exists // Delete current image(if any) and rebuild. // Use spawn() and {stdio: 'inherit'} so we can see the logs in real time. if (!dockerfileExists || stdout === '' || versionOpt || forceOpt) { if (stdout !== '') { exec('docker image rm matchstick', (_error, stdout, _stderr) => { this.log(`Remove matchstick image result:\n${stdout}`); }); } // Build a docker image. If the process has executed successfully // run a container from that image. spawn('docker', ['build', '-f', dockerfilePath, '-t', 'matchstick', '.'], { stdio: 'inherit', }).on('close', code => { if (code === 0) { spawn('docker', dockerRunOpts, { stdio: 'inherit' }); } }); } else { this.log('Docker image already exists. Skipping `docker build` command...'); // Run the container from the existing matchstick docker image spawn('docker', dockerRunOpts, { stdio: 'inherit' }); } }); } // Downloads Dockerfile template from the demo-subgraph repo // Replaces the placeholders with their respective values async function dockerfile(dockerfilePath, versionOpt, latestVersion) { const spinner = print.spin('Generating Dockerfile...'); try { // Fetch the Dockerfile template content from the demo-subgraph repo const content = await fetch('https://raw.githubusercontent.com/LimeChain/demo-subgraph/main/Dockerfile').then(response => { if (response.ok) { return response.text(); } throw new Error(`Status Code: ${response.status}, with error: ${response.statusText}`); }); // Write the Dockerfile filesystem.write(dockerfilePath, content); // Replaces the version placeholders await patching.replace(dockerfilePath, '<MATCHSTICK_VERSION>', versionOpt || latestVersion || 'unknown'); } catch (error) { this.error(`A problem occurred while generating the Dockerfile:\n${error.message}`, { exit: 1, }); } spinner.succeed('Successfully generated Dockerfile.'); }