UNPKG

@cto.ai/ops

Version:

💻 CTO.ai - The CLI built for Teams 🚀

571 lines (570 loc) 26.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const fs = tslib_1.__importStar(require("fs-extra")); const fuzzy_1 = tslib_1.__importDefault(require("fuzzy")); const path = tslib_1.__importStar(require("path")); const base_1 = tslib_1.__importStar(require("./../base")); const CustomErrors_1 = require("./../errors/CustomErrors"); const uuid_1 = tslib_1.__importDefault(require("uuid")); const env_1 = require("./../constants/env"); const opConfig_1 = require("./../constants/opConfig"); const OpsYml_1 = require("./../types/OpsYml"); const utils_1 = require("./../utils"); const runUtils_1 = require("./../utils/runUtils"); const validate_1 = require("./../utils/validate"); const build_1 = tslib_1.__importDefault(require("./build")); class Run extends base_1.default { constructor() { super(...arguments); this.customParse = (options, argv) => { // tslint:disable-next-line:no-implicit-dependencies TODO const { args, flags: inputFlags } = require('@oclif/parser').parse(argv, Object.assign(Object.assign({}, options), { context: this })); if (!args.nameOrPath && !inputFlags.help) { throw new CustomErrors_1.MissingRequiredArgument('ops run'); } if (!args.nameOrPath) { this._help(); } return { args, flags: inputFlags, opParams: argv.slice(1) }; }; this.parseYamlFile = async (relativePathToOpsYml) => { let manifestText = ''; try { manifestText = await fs.readFile(path.join(path.resolve(relativePathToOpsYml), opConfig_1.OP_FILE), 'utf8'); } catch (err) { // An error probably means that the file doesn't exist // so we just want a blank manifest if (err) { manifestText = ''; } } return (0, utils_1.parseYaml)(manifestText); }; // get all the commands and workflows in an ops.yml that match the nameOrPath this.readOpsFromLocalManifest = (relativePathToOpsYml) => async (inputs) => { this.debug(`loading YAML file in dir ${relativePathToOpsYml}`); const yamlContents = await this.parseYamlFile(relativePathToOpsYml); let { ops } = yamlContents; const { pipelines, services } = yamlContents; if (ops) { ops = (0, OpsYml_1.checkAndApplyLatestSDKVersion)(ops); } if (services) { ops = ops.concat((0, OpsYml_1.convertServicesToOps)(services)); } this.debug(`found ${ops.length} commands and ${pipelines.length} pipelines in YAML`); return Object.assign(Object.assign({}, inputs), { commands: ops, pipelines }); }; this.addMissingApiFieldsToLocalOps = async (inputs) => { const { commands, config } = inputs; return Object.assign(Object.assign({}, inputs), { commands: commands.map((command) => (Object.assign(Object.assign({}, command), { teamName: config.team.name, type: opConfig_1.COMMAND_TYPE }))) }); }; this.filterLocalOps = (inputs) => { const { commands, pipelines, parsedArgs: { args: { nameOrPath }, }, } = inputs; const matchName = ({ name }) => name.includes(nameOrPath); return Object.assign(Object.assign({}, inputs), { commands: commands.filter(matchName), pipelines: pipelines.filter(matchName) }); }; this.formatOpEmoji = (op) => { if (!op.isPublished) { return '🖥 '; } else if (op.isPublic) { return '🌎 '; } else { return '🔑 '; } }; this.formatOpName = (op) => { const name = this.ux.colors.reset.white(op.name); // TODO: make the command vs pipeline logic more explicit if ((0, runUtils_1.isCommand)(op)) { // this is a command return `${this.ux.colors.reset(this.ux.colors.multiBlue('\u2022'))} ${this.formatOpEmoji(op)} ${name}`; } else { // TODO: we're hard-coding the pipeline emoji for now return `${this.ux.colors.reset(this.ux.colors.multiOrange('\u2022'))} ${this.formatOpEmoji(op)} ${name}`; } }; this.fuzzyFilterParams = (ops) => { const list = ops.map(op => { const name = this.formatOpName(op); return { name: `${name} - ${op.description || ''}`, value: op, }; }); return list; }; this.autocompleteSearch = (ops) => async (_, input = '') => { try { return fuzzy_1.default .filter(input, this.fuzzyFilterParams(ops), { extract: el => el.name, }) .map(result => result.original); } catch (err) { this.debug('%O', err); throw err; } }; this.selectOpOrWorkflowToRun = async (inputs) => { try { const { commands, pipelines } = inputs; const ops = [...commands, ...pipelines]; if (ops.length === 0) { throw new CustomErrors_1.InvalidOpName(); } if (ops.length === 1) { this.debug(`Only one workflow available, selecting workflow: ${ops[0].name}`); return Object.assign(Object.assign({}, inputs), { selectedOp: ops[0] }); } const { selectedOp } = await this.ux.prompt({ message: `\nSelect a ${this.ux.colors.multiBlue('\u2022Command')}, ${this.ux.colors.multiOrange('\u2022Pipeline')} or ${this.ux.colors.multiPurple('\u2022Service')} to run ${this.ux.colors.reset(this.ux.colors.green('→'))}\n${this.ux.colors.reset(this.ux.colors.dim('🌎 = Public Registry 🔑 = Private Registry 🖥 = Local 🔍 Search:'))} `, name: 'selectedOp', pageSize: 5, source: this.autocompleteSearch(ops).bind(this), type: 'autocomplete', }); return Object.assign(Object.assign({}, inputs), { selectedOp }); } catch (err) { this.debug('%O', err); throw err; } }; this.printCustomHelp = (op) => { try { if (!op.help) { throw new Error('Custom help message can be defined in the ops.yml\n'); } switch (true) { case Boolean(op.description): this.log(`\n${op.description}`); case Boolean(op.help.usage): this.log(`\n${this.ux.colors.bold('USAGE')}`); this.log(` ${op.help.usage}`); case Boolean(op.help.arguments): this.log(`\n${this.ux.colors.bold('ARGUMENTS')}`); Object.keys(op.help.arguments).forEach(a => { this.log(` ${a} ${this.ux.colors.dim(op.help.arguments[a])}`); }); case Boolean(op.help.options): this.log(`\n${this.ux.colors.bold('OPTIONS')}`); Object.keys(op.help.options).forEach(o => { this.log(` -${o.substring(0, 1)}, --${o} ${this.ux.colors.dim(op.help.options[o])}`); }); } } catch (err) { this.debug('%O', err); throw err; } }; this.checkForHelpMessage = (inputs) => { try { const { parsedArgs: { flags: { help }, }, selectedOp, } = inputs; // TODO add support for workflows help if (help && (0, runUtils_1.isCommand)(selectedOp)) { this.printCustomHelp(selectedOp); process.exit(); } return inputs; } catch (err) { this.debug('%O', err); throw err; } }; this.getPublishedPipelines = async (inputs) => { try { const { data: opResults } = await this.services.api.find(`/private/teams/${inputs.config.team.name}/ops`, { headers: { Authorization: this.accessToken, }, }); return opResults; } catch (err) { this.debug('%0', err); throw new CustomErrors_1.APIError(err); } }; this.isPipelinePublished = async (inputs) => { const published = await this.getPublishedPipelines(inputs); const results = published.find(publishedPipeline => { return publishedPipeline.name.includes(inputs.selectedOp.name); }); if (results) { // check if the published version is the same as in the yaml if (results.version !== inputs.selectedOp.version) { this.log(this.ux.colors.whiteBright(`❗ The pipeline version published (${results.version}) differs from the pipeline version (${inputs.selectedOp.version}) specified in the ops.yml.`)); } } return !!results; }; this.getPipelineJob = async (teamName, opName, opVersion) => { let apiOp; try { let apiUrl = `/private/teams/${teamName}/ops/${opName}`; if (opVersion) { apiUrl += `/versions/${opVersion}`; } ; ({ data: apiOp } = await this.services.api.find(apiUrl, { headers: { Authorization: this.accessToken, }, })); apiOp.isPublished = true; return apiOp; } catch (err) { this.debug('%O', err); } return null; }; this.executeOpService = async (inputs) => { try { let isPipeline = false; const runID = (0, uuid_1.default)(); const { config, parsedArgs, teamName, opVersion, selectedOp, options, } = inputs; this.debug(`selected workflow %O`, selectedOp); if (!(0, runUtils_1.isCommand)(selectedOp) && (0, OpsYml_1.instanceOfOpPipeline)(selectedOp)) { isPipeline = true; if (parsedArgs.flags.build) { // HANDLE RUNNING AN UNPUBLISHED PIPELINE await build_1.default.run([inputs.parsedArgs.args.nameOrPath], this.config); parsedArgs.flags.build = false; } let i = 0; for (const op of selectedOp.jobs) { const jobOpToRun = op; // const jobOpToRun = builtCmds.find(cmd => { // return cmd.name === op.name // }) jobOpToRun.runId = runID; jobOpToRun.sdk = op.sdk || '2'; jobOpToRun.version = selectedOp.version; jobOpToRun.run = 'bash /ops/main.sh'; jobOpToRun.type = opConfig_1.JOB_TYPE; jobOpToRun.bind = op.bind || selectedOp.bind || []; if (selectedOp.env) { const envTeamName = config.team ? config.team.name : selectedOp.teamName; jobOpToRun.env = await this.parseYmlEnvVariables(Object.assign({}, selectedOp.env), envTeamName); } i++; if (i === selectedOp.jobs.length) { isPipeline = false; } // NOTE: We _might_ want to check the Pipeline image's architecture // and if it doesn't match with the host's chipset, then suggest the // user to rerun the `ops build .` command. // // https://cto-ai.atlassian.net/browse/PROD-2458 await this.services.opService.run(jobOpToRun, isPipeline, parsedArgs, config, opVersion, options); } process.exit(0); } else if ((0, OpsYml_1.instanceOfOpWorkflow)(selectedOp)) { if (await this.isPipelinePublished(inputs)) { if (selectedOp.steps) { // HANDLE RUNNING A PUBLISHED PIPELINE for (const step of selectedOp.steps) { const stepSuffix = step.replace('ops run ', ''); const stepName = stepSuffix.split(':')[0]; const stepVersion = stepSuffix.split(':')[1]; const apiOp = await this.getPipelineJob(config.team.name, stepName, stepVersion); if (apiOp) { apiOp.runId = runID; const envTeamName = config.team ? config.team.name : selectedOp.teamName; apiOp.env = await this.parseYmlEnvVariables(Object.assign({}, selectedOp.env), envTeamName); await this.services.opService.run(apiOp, isPipeline, parsedArgs, config, opVersion, options); } } process.exit(0); } } } if ((0, runUtils_1.isCommand)(selectedOp)) { this.debug(`Selected workflow is a command`); // HANDLE RUNNING SINGLE COMMANDS let { bind } = selectedOp; const { mountCwd = false, mountHome = false } = selectedOp; bind = bind || []; if (!selectedOp.isPublished) { selectedOp.teamName = selectedOp.teamName || teamName; } if (selectedOp.env) { const envTeamName = config.team ? config.team.name : selectedOp.teamName; selectedOp.env = await this.parseYmlEnvVariables(Object.assign({}, selectedOp.env), envTeamName); } selectedOp.runId = runID; await this.services.opService.run(selectedOp, isPipeline, parsedArgs, config, opVersion, options); return Object.assign(Object.assign({}, inputs), { selectedOp }); } } catch (err) { this.debug('%O', err); throw err; } return inputs; }; /** * Extracts the Workflow Team and Name from the input argument * @cto.ai/github -> { teamName: cto.ai, opname: github, opVersion: '' } * cto.ai/github -> { teamName: cto.ai, opname: github, opVersion: '' } * github -> { teamName: '', opname: github, opVersion: '' } * @cto.ai/github:0.1.0 -> { teamName: cto.ai, opname: github, opVersion: '0.1.0' } * cto.ai/github:customVersion -> { teamName: cto.ai, opname: github, opVersion: 'customVersion' } * github:myVersion -> { teamName: '', opname: github, opVersion: 'myVersion' } * cto.ai/extra/blah -> InvalidOpName * cto.ai/extra:version1:version2 -> InvalidOpName * null -> InvalidOpName */ this.parseTeamOpNameVersion = (inputs) => { const { parsedArgs: { args: { nameOrPath }, }, config: { team: { name: configTeamName }, }, } = inputs; const splits = nameOrPath.split('/'); if (splits.length === 0 || splits.length > 2) { throw new CustomErrors_1.InvalidOpName(); } if (splits.length === 1) { const [currentOpNameAndVersion] = splits; const { name, version } = (0, utils_1.splitNameAndVersion)(currentOpNameAndVersion); if (!(0, validate_1.isValidOpName)(name)) { throw new CustomErrors_1.InvalidOpName(); } return Object.assign(Object.assign({}, inputs), { teamName: configTeamName, opName: name, opVersion: version }); } let teamName = splits[0]; const opNameAndVersion = splits[1]; teamName = teamName.startsWith('@') ? teamName.substring(1, teamName.length) : teamName; teamName = (0, validate_1.isValidTeamName)(teamName) ? teamName : ''; const { name: opName, version: opVersion } = (0, utils_1.splitNameAndVersion)(opNameAndVersion); if (!(0, validate_1.isValidOpName)(opName)) { throw new CustomErrors_1.InvalidOpName(); } return Object.assign(Object.assign({}, inputs), { teamName, opName, opVersion }); }; this.parseYmlEnvVariables = async (env, teamName) => { const parsedEnv = []; if (env.configs) { env.configs.map(v => parsedEnv.push(v)); const configsArray = await this.getEnvConfigs(env.configs, teamName); parsedEnv.push(...configsArray); delete env.configs; } if (env.secrets) { env.secrets.map(v => parsedEnv.push(v)); const secretsArray = await this.getEnvSecrets(env.secrets, teamName); parsedEnv.push(...secretsArray); delete env.secrets; } if (env.static) { env.static.map(v => parsedEnv.push(v)); parsedEnv.push(...env.static); delete env.static; } // This only exists for older workflows env variables if (env.length > 0) { env.map(v => parsedEnv.push(v)); parsedEnv.push(...env); } return parsedEnv; }; this.getEnvConfigs = async (envs, teamName) => { const parsedEnv = []; if (envs) { for (const v of envs) { const { envKey, fetchKey } = this.splitEnvKeys(v); let value = ''; try { const { data } = await this.services.api.find(`/private/teams/${teamName}/configs/${fetchKey}`, { headers: { Authorization: this.accessToken, }, }); value = data; } catch (err) { this.debug('%O', err); const code = err.error[0].code; if (code !== 404) { throw new CustomErrors_1.APIError(err); } } parsedEnv.push(`${envKey}=${value}`); } } return parsedEnv; }; this.getEnvSecrets = async (envs, teamName) => { const parsedEnv = []; if (envs) { for (const v of envs) { const { envKey, fetchKey } = this.splitEnvKeys(v); let value = ''; try { const { data } = await this.services.api.find(`/private/teams/${teamName}/secret/${fetchKey}`, { headers: { Authorization: this.accessToken, }, }); value = data; } catch (err) { this.debug('Error %0', err); } parsedEnv.push(`${envKey}=${value}`); } } return parsedEnv; }; this.getApiOps = async (inputs) => { const { config, opName, commands: previousCommands = [], opVersion, } = inputs; let { teamName } = inputs; let apiOp; try { if (!opName) { return Object.assign({}, inputs); } teamName = teamName || config.team.name; let apiUrl = `/private/teams/${teamName}/ops/${opName}`; if (opVersion) { apiUrl += `/versions/${opVersion}`; } ; ({ data: apiOp } = await this.services.api.find(apiUrl, { headers: { Authorization: this.accessToken, }, })); } catch (err) { this.debug('%O', err); if (err.error[0].code === 404) { throw new CustomErrors_1.NoOpsFound(`@${teamName}/${opName}:${opVersion}`); } // TODO: these codepaths are dead with the current API if (err.error[0].code === 4011) { throw new CustomErrors_1.UnauthorizedtoAccessOp(err); } if (err.error[0].code === 4033) { throw new CustomErrors_1.NoTeamFound(teamName); } throw new CustomErrors_1.APIError(err); } if (!apiOp && !previousCommands.length) { throw new CustomErrors_1.NoOpsFound(opName, teamName); } if (!apiOp) { return Object.assign({}, inputs); } if ((0, validate_1.validateOpIsJob)(apiOp)) { throw new CustomErrors_1.RunningJobException(opName); } apiOp.isPublished = true; return Object.assign(Object.assign({}, inputs), { commands: [...previousCommands, apiOp] }); }; this.sendAnalytics = async (inputs) => { const { selectedOp: { id, name, description, version, teamName }, parsedArgs: { opParams }, config, } = inputs; this.services.analytics.track('Ops CLI Run', { argments: opParams.length, cliVersion: this.config.version, description, email: config.user.email, id, image: `${env_1.OPS_REGISTRY_HOST}/${name}:${version}`, name, namespace: `${teamName}/${name}`, namespace_version: `${teamName}/${name}:${version}`, runtime: 'CLI', team: teamName, username: config.user.username, version, }, config); return inputs; }; } splitEnvKeys(e) { const keys = e.split('='); const envKey = keys[0]; let fetchKey = keys[1]; if (!fetchKey) { fetchKey = envKey; } return { envKey, fetchKey }; } async run() { const config = await this.isLoggedIn(); try { const parsedArgs = this.customParse(Run, this.argv); const { flags: { nocache }, args: { nameOrPath }, } = parsedArgs; if ((0, runUtils_1.checkPathOpsYmlExists)(nameOrPath)) { // The nameOrPath argument is a directory containing an ops.yml const runFsPipeline = (0, utils_1.asyncPipe)(this.readOpsFromLocalManifest(nameOrPath), this.addMissingApiFieldsToLocalOps, this.selectOpOrWorkflowToRun, this.checkForHelpMessage, this.sendAnalytics, this.executeOpService); await runFsPipeline({ parsedArgs, config, options: { nocache } }); } else { /* * nameOrPath is either the name of a workflow and not a directory, or a * directory which does not contain an ops.yml. */ const runApiPipeline = (0, utils_1.asyncPipe)(this.readOpsFromLocalManifest(process.cwd()), this.addMissingApiFieldsToLocalOps, this.filterLocalOps, this.parseTeamOpNameVersion, this.getApiOps, this.selectOpOrWorkflowToRun, this.checkForHelpMessage, this.sendAnalytics, this.executeOpService); await runApiPipeline({ parsedArgs, config, options: { nocache } }); } } catch (err) { this.debug('%O', err); this.config.runHook('error', { accessToken: config.tokens.accessToken, err, }); } } } exports.default = Run; Run.description = 'Run a workflow from your team or the registry.'; Run.flags = { batch: base_1.flags.boolean({ char: 'B', default: false, description: 'Runs the workflow in non-interactive batch mode.', }), build: base_1.flags.boolean({ char: 'b', default: false, description: 'Builds the workflow before running. Must provide a path to the workflow.', }), help: base_1.flags.boolean({ char: 'h', description: 'show CLI help', }), nocache: base_1.flags.boolean({ default: false, description: 'Do not use cache when building the image', }), }; // Used to specify variable length arguments Run.strict = false; Run.args = [ { description: 'Name or path of the workflow you want to run.', name: 'nameOrPath', parse: (input) => { return input.toLowerCase(); }, }, ];