@cto.ai/ops
Version:
💻 CTO.ai - The CLI built for Teams 🚀
571 lines (570 loc) • 26.4 kB
JavaScript
"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();
},
},
];