@cto.ai/ops-rc
Version:
š» CTO.ai Ops - The CLI built for Teams š
413 lines (412 loc) ⢠21 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const sdk_1 = require("@cto.ai/sdk");
const fs = tslib_1.__importStar(require("fs-extra"));
const path = tslib_1.__importStar(require("path"));
const base_1 = tslib_1.__importStar(require("../base"));
const env_1 = require("../constants/env");
const opConfig_1 = require("../constants/opConfig");
const CustomErrors_1 = require("./../errors/CustomErrors");
const utils_1 = require("./../utils");
const get_docker_1 = tslib_1.__importDefault(require("../utils/get-docker"));
const validate_1 = require("../utils/validate");
const ErrorTemplate_1 = require("./../errors/ErrorTemplate");
const OpsYml_1 = require("./../types/OpsYml");
class Publish extends base_1.default {
constructor() {
super(...arguments);
this.resolvePath = (opPath) => {
return path.resolve(process.cwd(), opPath);
};
this.checkDocker = async () => {
const docker = await get_docker_1.default(this, 'publish');
if (!docker) {
throw new Error('No docker');
}
};
this.getOpsAndWorkflows = async (opPath) => {
const manifest = await fs
.readFile(path.join(opPath, opConfig_1.OP_FILE), 'utf8')
.catch((err) => {
this.debug('%O', err);
throw new CustomErrors_1.FileNotFoundError(err, opPath, opConfig_1.OP_FILE);
});
if (!manifest)
throw new CustomErrors_1.NoLocalOpsOrPipelinesFound();
const parsedManifest = utils_1.parseYaml(manifest);
const totalItems = parsedManifest.ops.length +
parsedManifest.workflows.length +
parsedManifest.pipelines.length +
parsedManifest.services.length;
if (totalItems === 0) {
//TODO change error
throw new CustomErrors_1.NoLocalOpsOrWorkflowsFound();
}
return parsedManifest;
};
this.convertPipelinesToWorkflows = (pipelines) => {
const pipelineCommands = [];
const pipelineWorkflows = pipelines.map(pipeline => {
const runSteps = pipeline.jobs.map(({ name }) => {
return 'ops run ' + name + ':' + pipeline.version;
});
const newWorkflow = {
name: pipeline.name,
version: pipeline.version,
description: 'auto generated pipeline job description for: ' + pipeline.name + ' ',
type: opConfig_1.WORKFLOW_TYPE,
remote: true,
steps: runSteps,
};
//convert each pipeline job to a OpCommand
pipelineCommands.push(...pipeline.jobs.map(({ name }) => {
let { ops } = utils_1.parseYaml(fs.readFileSync(`/tmp/${name}/ops.yml`, 'utf8'));
return ops[0];
}));
return newWorkflow;
});
return { pipelineWorkflows, pipelineCommands };
};
this.choosePublishKind = async (commands, pipelines) => {
if (commands.length !== 0 && pipelines.length !== 0) {
const { commandsOrPipelines } = await sdk_1.ux.prompt({
type: 'list',
name: 'commandsOrPipelines',
message: `\n Which would you like to publish ${sdk_1.ux.colors.reset.green('ā')}`,
choices: [
{ name: 'Commands', value: opConfig_1.COMMAND },
{ name: 'Pipelines', value: opConfig_1.PIPELINE },
],
afterMessage: `${sdk_1.ux.colors.reset.green('ā')}`,
});
return commandsOrPipelines;
}
else if (commands && commands.length) {
return opConfig_1.COMMAND;
}
else if (pipelines && pipelines.length) {
return opConfig_1.PIPELINE;
}
else {
this.debug('found neither valid commands or pipelines');
process.exit(1);
}
};
this.selectOpsAndWorkflows = async (commandsOrPipelines, commands, workflows) => {
switch (commandsOrPipelines) {
case opConfig_1.COMMAND:
const selectedCommands = await this.selectOps(commands);
return { selectedCommands, selectedWorkflows: [] };
case opConfig_1.PIPELINE:
const selectedWorkflows = await this.selectWorkflows(workflows);
return { selectedCommands: [], selectedWorkflows };
default:
this.debug('found neither valid commands or pipelines');
process.exit(1);
}
};
this.selectOps = async (ops) => {
if (ops.length <= 1) {
return ops;
}
const answers = await sdk_1.ux.prompt({
type: 'checkbox',
name: 'ops',
message: `\n Which ops would you like to publish ${sdk_1.ux.colors.reset.green('ā')}`,
choices: ops.map(op => {
return {
value: op,
name: `${op.name} - ${op.description}`,
};
}),
validate: input => input.length > 0,
});
return answers.ops;
};
this.selectWorkflows = async (workflows) => {
if (workflows.length <= 1) {
return workflows;
}
const answers = await sdk_1.ux.prompt({
type: 'checkbox',
name: 'workflows',
message: `\n Which workflows would you like to publish ${sdk_1.ux.colors.reset.green('ā')}`,
choices: workflows.map(workflow => {
return {
value: workflow,
name: `${workflow.name} - ${workflow.description}`,
};
}),
validate: input => input.length > 0,
});
return answers.workflows;
};
this.checkForExistingVersion = async (name, version) => {
try {
await this.services.api.find(`/private/teams/${this.team.name}/ops/${name}/versions/${version}`, {
headers: {
Authorization: this.accessToken,
},
});
return true;
}
catch (err) {
if (err.error[0].code === 404) {
return false;
}
throw new CustomErrors_1.APIError(err);
}
};
this.ensureAvailableVersions = async (opPath, commandsOrPipelines, commands, workflows) => {
if (commandsOrPipelines === opConfig_1.PIPELINE) {
for (const workflow of workflows) {
if (await this.checkForExistingVersion(workflow.name, workflow.version)) {
this.log('\n š¤ It seems like the version of the pipeline that you are trying to publish already taken. \n Please update the pipeline version in the ops.yml, rebuild and try again.');
process.exit(1);
}
}
return { commands, workflows };
}
// Going to assume we're doing commands
const updatedCommands = [];
let manifest = await fs.readFile(path.join(opPath, opConfig_1.OP_FILE), 'utf8');
for (const command of commands) {
// Ignore anything that isn't existing
if (!(await this.checkForExistingVersion(command.name, command.version))) {
updatedCommands.push(command);
continue;
}
this.log(`${this.ux.colors.callOutCyan(`Current version for ${command.name}:`)} ${this.ux.colors.white(command.version)}`);
const { newVersion } = await this.ux.prompt({
type: 'input',
name: 'newVersion',
message: '\nāļø Update version:',
transformer: input => {
return this.ux.colors.white(input);
},
validate: async (input) => {
if (input === '')
return 'Please enter a version';
if (!validate_1.validVersionChars.test(input)) {
return 'ā Sorry, version is required and can only contain letters, digits, underscores, \n periods and dashes and must start and end with a letter or a digit';
}
if (await this.checkForExistingVersion(command.name, input)) {
return 'That version is already taken';
}
return true;
},
});
manifest = manifest.replace(`name: ${command.name}:${command.version}`, `name: ${command.name}:${newVersion}`);
command.version = newVersion;
if (command.type === opConfig_1.COMMAND_TYPE || command.type === opConfig_1.SERVICE_TYPE) {
const opImageTag = utils_1.getOpImageTag(this.team.name, command.name, command.version, command.isPublic);
const image = utils_1.getOpUrl(env_1.OPS_REGISTRY_HOST, opImageTag);
await this.services.imageService.build(image, path.resolve(process.cwd(), opPath), command);
}
updatedCommands.push(command);
}
return { commands: updatedCommands, workflows };
};
this.getRegistryAuth = async (name, version) => {
try {
const registryAuth = await this.services.registryAuthService.create(this.accessToken, this.team.name, name, version, false, true);
return registryAuth;
}
catch (err) {
throw new CustomErrors_1.CouldNotGetRegistryToken(err);
}
};
this.pipelineCommandsPublishLoop = async (pipelineCommands, version, config) => {
for (const op of pipelineCommands) {
if (!validate_1.isValidOpName(op.name)) {
throw new CustomErrors_1.InvalidInputCharacter('Op Name');
}
if (!validate_1.isValidOpVersion(op.version)) {
throw new CustomErrors_1.InvalidOpVersionFormat();
}
op.publishDescription =
'auto generated publish description for pipeline job ' + op.name;
try {
const opName = utils_1.getOpImageTag(this.team.name, op.name, op.version, op.isPublic);
const localImage = await this.services.imageService.checkLocalImage(`${env_1.OPS_REGISTRY_HOST}/${opName}`);
if (!localImage) {
throw new CustomErrors_1.DockerPublishNoImageFound(op.name, this.team.name);
}
op.type = opConfig_1.JOB_TYPE;
const { data: apiOp, } = await this.services.publishService.publishOpToAPI(op, version, this.team.name, this.accessToken, this.services.api);
const registryAuth = await this.getRegistryAuth(op.name, op.version);
await this.services.publishService.publishOpToRegistry(apiOp, registryAuth, this.team.name, this.accessToken, this.services.api);
this.sendAnalytics('op', apiOp, config);
}
catch (err) {
if (err instanceof ErrorTemplate_1.ErrorTemplate) {
throw err;
}
throw new CustomErrors_1.APIError(err);
}
}
};
this.opsPublishLoop = async (opCommands, version, config) => {
try {
for (const op of opCommands) {
if (!validate_1.isValidOpName(op.name)) {
throw new CustomErrors_1.InvalidInputCharacter('Op Name');
}
if (!validate_1.isValidOpVersion(op.version)) {
throw new CustomErrors_1.InvalidOpVersionFormat();
}
const { publishDescription } = await this.ux.prompt({
type: 'input',
name: 'publishDescription',
message: `\nProvide a publish description for ${op.name}:${op.version} ${sdk_1.ux.colors.reset.green('ā')}\n\n ${sdk_1.ux.colors.white('Description:')}`,
afterMessage: sdk_1.ux.colors.reset.green('ā'),
afterMessageAppend: sdk_1.ux.colors.reset(' added!'),
validate: this._validateDescription,
});
op.publishDescription = publishDescription;
const opName = utils_1.getOpImageTag(this.team.name, op.name, op.version, op.isPublic);
const localImage = await this.services.imageService.checkLocalImage(`${env_1.OPS_REGISTRY_HOST}/${opName}`);
if (!localImage) {
throw new CustomErrors_1.DockerPublishNoImageFound(op.name, this.team.name);
}
// TODO: What do we do if this isn't true
if ('run' in op) {
const { data: apiOp, } = await this.services.publishService.publishOpToAPI(op, version, this.team.name, this.accessToken, this.services.api);
const registryAuth = await this.getRegistryAuth(op.name, op.version);
await this.services.publishService.publishOpToRegistry(apiOp, registryAuth, this.team.name, this.accessToken, this.services.api);
this.sendAnalytics('op', apiOp, config);
}
}
}
catch (err) {
if (err instanceof ErrorTemplate_1.ErrorTemplate) {
throw err;
}
throw new CustomErrors_1.APIError(err);
}
};
this.workflowsPublishLoop = async (opWorkflows, version, config) => {
try {
for (const workflow of opWorkflows) {
if (!validate_1.isValidOpName(workflow.name)) {
throw new CustomErrors_1.InvalidInputCharacter('Workflow Name');
}
if (!validate_1.isValidOpVersion(workflow.version)) {
throw new CustomErrors_1.InvalidOpVersionFormat();
}
const { publishDescription } = await this.ux.prompt({
type: 'input',
name: 'publishDescription',
message: `\nProvide a publish description for ${workflow.name}:${workflow.version} ${sdk_1.ux.colors.reset.green('ā')}\n\n ${sdk_1.ux.colors.white('Description:')}`,
afterMessage: sdk_1.ux.colors.reset.green('ā'),
afterMessageAppend: sdk_1.ux.colors.reset(' added!'),
validate: this._validateDescription,
});
workflow.publishDescription = publishDescription;
if ('remote' in workflow && workflow.remote) {
for (const step of workflow.steps) {
if (!step.includes('ops run')) {
this.debug('InvalidStepsFound - Step:', step);
throw new CustomErrors_1.InvalidStepsFound(step);
}
}
}
try {
const { data: apiWorkflow, } = await this.services.api.create(`/private/teams/${this.team.name}/ops`, Object.assign(Object.assign({}, workflow), { platformVersion: version, type: 'workflow' }), {
headers: {
Authorization: this.accessToken,
},
});
this.log(`\nš ${sdk_1.ux.colors.callOutCyan(apiWorkflow.name)} has been published!`);
this.log(`š„ Visit your Op page here: ${sdk_1.ux.url(`${env_1.OPS_API_HOST}registry/${this.team.name}/${apiWorkflow.name}`, `<${env_1.OPS_API_HOST}${this.team.name}/${apiWorkflow.name}>`)}\n`);
this.sendAnalytics('workflow', apiWorkflow, config);
}
catch (err) {
this.debug('%O', err);
const InvalidWorkflowStepCodes = [400, 404];
if (err &&
err.error &&
err.error[0] &&
InvalidWorkflowStepCodes.includes(err.error[0].code)) {
if (err.error[0].message === 'version is taken') {
throw new CustomErrors_1.VersionIsTaken();
}
throw new CustomErrors_1.InvalidWorkflowStep(err);
}
throw new CustomErrors_1.CouldNotCreateWorkflow(err.message);
}
}
}
catch (err) {
if (err instanceof ErrorTemplate_1.ErrorTemplate)
throw err;
throw new CustomErrors_1.APIError(err);
}
};
this.sendAnalytics = (publishType, opOrWorkflow, config) => {
this.services.analytics.track('Ops CLI Publish', {
name: opOrWorkflow.name,
team: config.team.name,
namespace: `@${config.team.name}/${opOrWorkflow.name}`,
username: config.user.username,
type: publishType,
description: opOrWorkflow.description,
image: `${env_1.OPS_REGISTRY_HOST}/${opOrWorkflow.id.toLowerCase()}:${opOrWorkflow.version}`,
tag: opOrWorkflow.version,
}, config);
};
}
_validateDescription(input) {
if (input === '')
return 'You need to provide a publish description of your op before continuing';
return true;
}
async run() {
const config = await this.isLoggedIn();
try {
const { args } = this.parse(Publish);
await this.checkDocker();
const opPath = this.resolvePath(args.path);
const manifest = await this.getOpsAndWorkflows(opPath);
const { pipelineWorkflows, pipelineCommands, } = this.convertPipelinesToWorkflows(manifest.pipelines);
const serviceOps = OpsYml_1.convertServicesToOps(manifest.services);
const commands = manifest.ops.concat(serviceOps);
const workflows = manifest.workflows.concat(pipelineWorkflows);
const commandsOrPipelines = await this.choosePublishKind(commands, manifest.pipelines);
const { selectedCommands, selectedWorkflows, } = await this.selectOpsAndWorkflows(commandsOrPipelines, commands, workflows);
const { commands: finalCommands, workflows: finalWorkflows, } = await this.ensureAvailableVersions(opPath, commandsOrPipelines, selectedCommands, selectedWorkflows);
switch (commandsOrPipelines) {
case opConfig_1.COMMAND:
await this.opsPublishLoop(finalCommands, manifest.version, config);
break;
case opConfig_1.PIPELINE:
await this.pipelineCommandsPublishLoop(pipelineCommands, manifest.version, config);
await this.workflowsPublishLoop(finalWorkflows, manifest.version, config);
break;
default:
this.debug('found neither valid commands or pipelines');
process.exit(1);
}
}
catch (err) {
this.debug('%O', err);
this.config.runHook('error', {
err,
accessToken: config.tokens.accessToken,
});
}
}
}
exports.default = Publish;
Publish.description = 'Publish an Op to your team.';
Publish.flags = {
help: base_1.flags.help({ char: 'h' }),
};
Publish.args = [
{
name: 'path',
description: 'Path to the op you want to publish.',
required: true,
},
];