UNPKG

@mountainpass/hooked-cli

Version:
212 lines (211 loc) 11 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; /* eslint-disable no-template-curly-in-string */ /* eslint-disable max-len */ import child_process from 'child_process'; import crypto from 'crypto'; import fs from 'fs'; import fsPromises from 'fs/promises'; import path from 'path'; import { CaptureWritableStream } from '../common/CaptureWritableStream.js'; import { isDefined, isDockerCmdScript, isSSHCmdScript } from '../types.js'; import ApplicationMode from '../utils/ApplicationMode.js'; import { Environment } from '../utils/Environment.js'; import fileUtils from '../utils/fileUtils.js'; import logger from '../utils/logger.js'; import { resolveResolveScript } from './ScriptExecutor.js'; export const randomString = () => crypto.randomBytes(20).toString('hex'); export const injectEnvironmentInScript = (content, env) => { // inject environment exports into content, IF provided if (isDefined(env)) { const REGEX_HAS_HASHBANG_LEADINGLINE = /^#![^\n]+\n/gm; const envexports = env.envToShellExports(); // inject environment variables at the head of the file... const match = REGEX_HAS_HASHBANG_LEADINGLINE.exec(content); if (match != null) { content = content.substring(0, REGEX_HAS_HASHBANG_LEADINGLINE.lastIndex) + envexports + content.substring(REGEX_HAS_HASHBANG_LEADINGLINE.lastIndex); } else { // NOTE: hashbang required, otherwise -> Underlying error: "spawn Unknown system error -8" content = "#!/bin/sh\n" + envexports + content; } } return content.endsWith('\n') ? content : (content + '\n'); }; /** * Writes an executable file. Optional injects environment variables into the file. * @param filepath * @param content * @param env - optional */ const writeScript = (filepath, content, env) => { const newContent = injectEnvironmentInScript(content, env); logger.debug(`Writing file - ${filepath}`); fs.writeFileSync(filepath, newContent, 'utf-8'); fs.chmodSync(filepath, 0o755); }; export const childProcesses = []; export const dockerNames = []; // type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void // interface ExecOutput { error: ExecException | null, stdout: string, stderr: string } // /** // * Internal function for running a process. // * @param cmd // * @param opts // * @param customOpts // * @returns // */ // export const createProcessAsync = async (cmd: string, opts: ExecSyncOptions, customOpts: CustomOptions): Promise<string> => { // logger.debug(`Creating process async - ${cmd}`) // // const buffer = child_process.execSync(cmd, { ...opts, stdio: customOpts.captureStdout ? undefined : customOpts.printStdio ? 'inherit' : 'ignore' }) // // const stdout = buffer !== null ? buffer.toString() : '' // const { stdout } = await new Promise<ExecOutput>((resolve, reject) => { // child_process.exec(cmd, { ...opts }, (error, stdout, stderr) => { // if (error != null) { // reject(error) // } else { // resolve({ error, stdout, stderr }) // } // }) // }) // if (!customOpts.captureStdout && customOpts.printStdio) { // logger.info(stdout) // } // return customOpts.captureStdout ? stdout : '' // } export const createProcess = (cmdFilePath, stdoutFilePath, stderrFilePath, opts, customOpts) => __awaiter(void 0, void 0, void 0, function* () { const inherit = !customOpts.captureStdout && customOpts.printStdio && ApplicationMode.getApplicationMode() !== 'test'; logger.debug(`Creating process sync - ${cmdFilePath} - inherit=${inherit ? 'Y' : 'N'}`); const stdoutFD = fs.openSync(stdoutFilePath, 'a'); const stderrFD = fs.openSync(stderrFilePath, 'a'); const sysout = fs.createWriteStream('', { fd: stdoutFD }); const syserr = fs.createWriteStream('', { fd: stderrFD }); const child = child_process.spawn(cmdFilePath, Object.assign(Object.assign({}, opts), { stdio: inherit ? 'inherit' : ['ignore', sysout, syserr] })); const exitCode = yield new Promise(resolve => child.on('close', resolve)); if (exitCode !== 0) { const error = new Error(`Command failed: ${cmdFilePath}`); error.status = exitCode; throw error; } // to facilitate testing... const stdoutContent = yield fsPromises.readFile(stdoutFilePath, { encoding: 'utf-8' }); const tmp = new CaptureWritableStream(); tmp.whenUpdated(stdoutContent); tmp.whenFinished(stdoutContent); // logger.debug(stdout.getCaptured()) return customOpts.captureStdout ? stdoutContent : ''; }); /** * Executes the provided multiline command, and returns the stdout as a string. * @param paths - the script paths * @param multilineCommand * @param dockerImage - optional - if provided, runs the command in a docker container. * @param opts * @returns */ export const executeCmd = (key_1, script_1, options_1, ...args_1) => __awaiter(void 0, [key_1, script_1, options_1, ...args_1], void 0, function* (key, script, options, opts = undefined, env, customOpts, timeoutMs) { var _a; // keep track of files that need to be cleaned up post run. const cleanupFiles = []; try { // N.B. use randomString to stop script clashes (e.g. when calling another hooked command, from this command!) const rand = randomString(); const scriptName = `${key.replace(/[^\w\d-]+/g, '')}-${rand}`; const filepath = fileUtils.resolvePath(`.tmp-${scriptName}.sh`); const stdoutpath = `${filepath}.stdout`; const stderrpath = `${filepath}.stderr`; const envfile = fileUtils.resolvePath(`.env-${scriptName}.txt`); const parent = (_a = options.dockerHookedDir) !== null && _a !== void 0 ? _a : path.dirname(filepath); const additionalOpts = { timeout: isDefined(timeoutMs) ? timeoutMs : undefined }; cleanupFiles.push(filepath); cleanupFiles.push(envfile); cleanupFiles.push(stdoutpath); cleanupFiles.push(stderrpath); // add "HOOKED_ROOT=false" to all child environments... env.putResolved('HOOKED_ROOT', 'false'); // run script based on underlying implementations if (isDockerCmdScript(script)) { // write a docker file, and an env file... const dockerfilepath = fileUtils.resolvePath(`.tmp-docker-${scriptName}.sh`); cleanupFiles.push(dockerfilepath); writeScript(dockerfilepath, script.$cmd); writeScript(envfile, env.envToDockerEnvfile()); const dockerName = rand; const DEFAULT_DOCKER_SCRIPT = 'docker run -t --rm --network host --entrypoint "" --env-file "${envfile}" -w "${parent}" -v "${parent}:${parent}" --name ${dockerName} ${dockerImage} /bin/sh -c "${filepath}"'; const { DOCKER_SCRIPT: dockerScript = DEFAULT_DOCKER_SCRIPT } = env.global; const cmd = resolveResolveScript('-', { $resolve: dockerScript }, new Environment().putAllGlobal({ envfile, filepath: path.join(parent, path.basename(dockerfilepath)), dockerImage: script.$image, dockerName, parent }), false); dockerNames.push(dockerName); // write a script to run the docker (include system env vars - these may be required e.g. DOCKER_HOST)... const tmp = env.clone().putAllResolved(process.env, false); writeScript(filepath, cmd, tmp); return yield createProcess(filepath, stdoutpath, stderrpath, Object.assign(Object.assign({}, additionalOpts), opts), customOpts); // end } else if (isSSHCmdScript(script)) { // run on remote machine const sshfilepath = fileUtils.resolvePath(`.tmp-ssh-${scriptName}.sh`); cleanupFiles.push(sshfilepath); writeScript(sshfilepath, script.$cmd, env); const DEFAULT_SSH_SCRIPT = 'ssh -q -T "${user_at_server}" < "${filepath}"'; const { SSH_SCRIPT: sshScript = DEFAULT_SSH_SCRIPT } = env.global; const sshConnection = resolveResolveScript('-', { $resolve: script.$ssh }, env, false); const cmd = resolveResolveScript('-', { $resolve: sshScript }, new Environment().putAllGlobal({ envfile, filepath: sshfilepath, user_at_server: sshConnection, parent }), false); // write a script to execute the shell script on the remote machine... (include system env vars - these may be required e.g. DOCKER_HOST)... const tmp = env.clone().putAllResolved(process.env, false); writeScript(filepath, cmd, tmp); return yield createProcess(filepath, stdoutpath, stderrpath, Object.assign(Object.assign({}, additionalOpts), opts), customOpts); // end } else { // otherwise fallback to running a script on the local machine writeScript(filepath, script.$cmd, env); return yield createProcess(filepath, stdoutpath, stderrpath, Object.assign(Object.assign({}, additionalOpts), opts), customOpts); // end } } catch (err) { const status = isDefined(err.status) ? err.status : err.code; const message = err.message; err.message = `Command failed with status code ${status}\n` + `Underlying error: "${message}"\n` + 'Consider adding a "set -ve" to your $cmd to see which line errored.'; throw err; } finally { // cleanup files... (if enabled!) if (options.skipCleanup !== true) { yield Promise.all(cleanupFiles.map((f) => __awaiter(void 0, void 0, void 0, function* () { logger.debug(`Removing file - ${f}`); if (fs.existsSync(f)) { try { yield fsPromises.unlink(f); } catch (err) { const message = err.message; if (!message.startsWith('ENOENT: no such file or directory')) { logger.warn(`WARN: could not delete file - ${f} Reason: ${err.message}`); } } } }))); } } });