UNPKG

@cto.ai/ops

Version:

šŸ’» CTO.ai - The CLI built for Teams šŸš€

655 lines (654 loc) • 33.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const fs = tslib_1.__importStar(require("fs-extra")); const path = tslib_1.__importStar(require("path")); const semver_1 = tslib_1.__importDefault(require("semver")); const cli_sdk_1 = require("@cto.ai/cli-sdk"); const strings_1 = require("../constants/strings"); 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"); const Docker_1 = require("../types/Docker"); class Publish extends base_1.default { constructor() { super(...arguments); this.resolvePath = (opPath) => { return path.resolve(process.cwd(), opPath); }; this.checkDocker = async () => { const docker = await (0, 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 = (0, 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, opPath) => { const pipelineCommands = []; const pipelineWorkflows = pipelines.map(pipeline => { const runSteps = pipeline.jobs.map(({ name, linked, params }) => { if (linked) { if (pipeline.linkedCommandsNames === null || pipeline.linkedCommandsNames === undefined) { pipeline.linkedCommandsNames = []; } pipeline.linkedCommandsNames.push(name); if (params !== undefined && params.length > 0 && (pipeline.env !== undefined || pipeline.env)) { if (pipeline.env.static === undefined || pipeline.env.static === null) { pipeline.env.static = []; } pipeline.env.static.push(...params); } } return linked ? 'ops run ' + name : 'ops run ' + name + ':' + pipeline.version; }); const newWorkflow = { name: pipeline.name, version: pipeline.version, description: pipeline.description, type: opConfig_1.WORKFLOW_TYPE, remote: pipeline.remote, isPublic: pipeline.isPublic || false, steps: runSteps, env: pipeline.env, events: pipeline.events, trigger: pipeline.trigger, bind: pipeline.bind, linkedCommandsNames: pipeline.linkedCommandsNames, }; //convert each pipeline job to a OpCommand pipelineCommands.push(...pipeline.jobs .filter(e => !e.linked) .map(({ name }) => { let { ops } = (0, utils_1.parseYaml)(fs.readFileSync(`${opPath}/.ops/jobs/${name}/ops.yml`, 'utf8')); if (ops.length > 0) { ops[0].isPublic = pipeline.isPublic || false; ops[0].version = pipeline.version; } return ops[0]; })); return newWorkflow; }); return { pipelineWorkflows, pipelineCommands }; }; this.choosePublishKind = async (commands, pipelines) => { if (commands.length !== 0 && pipelines.length !== 0) { const { commandsOrPipelines } = await cli_sdk_1.ux.prompt({ type: 'list', name: 'commandsOrPipelines', message: `\n Which would you like to publish ${cli_sdk_1.ux.colors.reset.green('→')}`, choices: [ { name: 'Commands', value: opConfig_1.COMMAND }, { name: 'Pipelines', value: opConfig_1.PIPELINE }, ], afterMessage: `${cli_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 cli_sdk_1.ux.prompt({ type: 'checkbox', name: 'ops', message: `\n Which workflows would you like to publish ${cli_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 cli_sdk_1.ux.prompt({ type: 'checkbox', name: 'workflows', message: `\n Which workflows would you like to publish ${cli_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.ensureAvailableVersion = async (opPath, op, options) => { const versionAlreadyPublished = await this.checkForExistingVersion(op.name, op.version); // We want to always rebuild the pipeline images if (!versionAlreadyPublished && op.type != opConfig_1.PIPELINE_TYPE) { return op; } if (versionAlreadyPublished) { let manifest = await fs.readFile(path.join(opPath, opConfig_1.OP_FILE), 'utf8'); this.log(`${this.ux.colors.callOutCyan(`Current version for ${op.name}:`)} ${this.ux.colors.white(op.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(op.name, input)) { return 'That version is already taken'; } return true; }, }); manifest = manifest.replace(`name: ${op.name}:${op.version}`, `name: ${op.name}:${newVersion}`); await fs.writeFile(path.join(opPath, opConfig_1.OP_FILE), manifest); op.version = newVersion; } switch (op.type) { case opConfig_1.COMMAND_TYPE: case opConfig_1.SERVICE_TYPE: const opImageTag = (0, utils_1.getOpImageTag)(this.team.name, op.name, op.version, op.isPublic); const image = (0, utils_1.getOpUrl)(env_1.OPS_REGISTRY_HOST, opImageTag); await this.services.imageService.build(image, path.resolve(process.cwd(), opPath), op, options); break; case opConfig_1.PIPELINE_TYPE: const commands = await this.services.opService.convertOpsToCommands([op], this.state.config, opPath); await this.services.opService.opsBuildLoop(commands, opPath, this.state.config, options); break; } return op; }; this.getMaxSemver = async (versions) => { try { return versions .filter(version => { return semver_1.default.valid(version, true); }) .map(function (version) { return { original: version, clean: semver_1.default.clean(version), }; }) .reduce(function (v1, v2) { return semver_1.default['gt'](v2.clean, v1.clean) ? v2 : v1; }).original; } catch (err) { this.debug('%O', err); // return empty semver when no workflow versions exist using semver return '0.0.0'; } }; this.bumpUpVersion = async (teamName, opName, version) => { try { const { data } = await this.services.api.find(`/private/teams/${teamName}/ops/${opName}/versions`, { headers: { Authorization: this.accessToken, }, }); const versions = data.map(op => { return op.version; }); version = await this.getMaxSemver(versions); const [major, minor, patch] = version.split('.'); const updatedMinor = Number(minor) + 1; const updatedVersion = `${major}.${updatedMinor}.${patch}`; return updatedVersion; } catch (err) { this.debug('%O', err); return '0.1.0'; } }; this.filterOps = (ops, names) => { return ops.filter(item => { for (let op of names) { if (item.name === op) { return true; } } }); }; 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.promptForChangelog = async (opName, opVersion) => { const { publishChangelog } = await this.ux.prompt({ type: 'input', name: 'publishChangelog', message: `\nProvide a publish changelog for ${opName}:${opVersion} ${cli_sdk_1.ux.colors.reset.green('→')}\n\n ${cli_sdk_1.ux.colors.white('Changelog:')}`, afterMessage: cli_sdk_1.ux.colors.reset.green('āœ“'), afterMessageAppend: cli_sdk_1.ux.colors.reset(' added!'), validate: this._validateChangelog, }); return publishChangelog; }; this.opsPublishLoop = async (opCommands, version, config, changelog) => { try { for (const op of opCommands) { if (!(0, validate_1.isValidOpName)(op.name)) { throw new CustomErrors_1.InvalidInputCharacter('Workflow Name'); } if (!(0, validate_1.isValidOpVersion)(op.version)) { throw new CustomErrors_1.InvalidOpVersionFormat(); } if (!changelog) { changelog = await this.promptForChangelog(op.name, op.version); } op.publishDescription = changelog; const opName = (0, 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); if (apiOp.events) { const builder = (apiOp && apiOp.trigger && apiOp.trigger.length > 0) || false; await this.services.subscriptionService.sendSubscriptions(apiOp.name, apiOp.id, op.type, apiOp.events, config, builder); } this.sendAnalytics('op', apiOp, config); } } } catch (err) { this.debug('%O', err); if (err instanceof ErrorTemplate_1.ErrorTemplate) { throw err; } throw new CustomErrors_1.APIError(err); } }; // Publish every single Pipeline Command (AKA Pipeline job) image this.pipelineCommandsPublishLoop = async (pipelineCommands, version, config) => { for (const op of pipelineCommands) { if (!(0, validate_1.isValidOpName)(op.name)) { throw new CustomErrors_1.InvalidInputCharacter('Workflow Name'); } if (!(0, validate_1.isValidOpVersion)(op.version)) { throw new CustomErrors_1.InvalidOpVersionFormat(); } op.publishDescription = strings_1.PIPELINE_PUBLISH_DESCRIPTION + op.name; try { const opName = (0, 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.sleep = async (ms) => { return new Promise(resolve => setTimeout(resolve, ms)); }; // Publish the entity that groups all the "Pipeline jobs" together this.workflowsPublishLoop = async (opWorkflows, version, config, changelog) => { try { for (const workflow of opWorkflows) { if (!(0, validate_1.isValidOpName)(workflow.name)) { throw new CustomErrors_1.InvalidInputCharacter('Workflow Name'); } if (!(0, validate_1.isValidOpVersion)(workflow.version)) { throw new CustomErrors_1.InvalidOpVersionFormat(); } if (!changelog) { changelog = await this.promptForChangelog(workflow.name, workflow.version); } workflow.publishDescription = changelog; 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); } } } // Added sleep to fix the timing issue // the api call below fails some times when it takes longer for registry and db insertions on the api side // TODO: fix the timing issue and remove sleep await this.sleep(2000); 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šŸ™Œ ${cli_sdk_1.ux.colors.callOutCyan(apiWorkflow.name)} has been published!`); this.log(`šŸ–„ View in our registry here: ${cli_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`); if (workflow.events) { await this.services.subscriptionService.sendSubscriptions(apiWorkflow.name, apiWorkflow.id, 'pipeline', workflow.events, config, false); } 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.eventsWarning = (opsToBuild, teamName) => { for (let i = 0; i < opsToBuild.length; i++) { if (opsToBuild[i].events) { const opName = cli_sdk_1.ux.colors.callOutCyan(opsToBuild[i].name); teamName = cli_sdk_1.ux.colors.callOutCyan(teamName); this.log(cli_sdk_1.ux.colors.actionBlue(`\nThe selected workflow ${opName} has events in the ops.yml\nbut your active team ${teamName} does not have our Github App Installed.`)); this.log(cli_sdk_1.ux.colors.actionBlue(`Visit ${env_1.WWW_HOST}/home to install our Github App`)); process.exit(); } } }; 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); }; this.validateBilling = async (config, op) => { const billing = await this.services.billingService.validateBilling(config.team.name, op.type, op.name, config.user.email, config.user.username, config.tokens.accessToken); if (billing.quantity < billing.maxFreeUnits) { console.log(`${cli_sdk_1.ux.colors.primary('Good news! You have')} ${cli_sdk_1.ux.colors.successGreen(`${billing.freeUnitsAvailable} free`)} ${cli_sdk_1.ux.colors.primary(`${billing.workflowType} left on your team's free plan.`)}`); } else if (billing.isNewWorkflow) { // TODO: make this dynamic later const prices = { Services: '$19', Pipelines: '$14', Commands: '$7', }; console.log(`\nšŸ“¦ ${cli_sdk_1.ux.colors.primary('You currently have')} ${cli_sdk_1.ux.colors.successGreen(`${billing.quantity} ${billing.workflowType}`)} ${cli_sdk_1.ux.colors.primary('published to')} ${cli_sdk_1.ux.colors.callOutCyan(`${config.team.name}`)}`); console.log(`šŸ’³ ${cli_sdk_1.ux.colors.primary('This will add')} ${cli_sdk_1.ux.colors.green(prices[billing.workflowType])} ${cli_sdk_1.ux.colors.primary('to your monthly team subscription.')} \n`); const { billingConfirm } = await cli_sdk_1.ux.prompt({ type: 'confirm', name: 'billingConfirm', suffix: false, message: `Are you sure you want to publish @${config.team.name}/${op.name}:${op.version}?`, }); if (!billingConfirm) { throw new Error('🚫 Terminating publish workflow. No further action taken.'); } } }; this._selectOption = async (ops, name) => { if (ops.length <= 1) { return ops; } let anwsers = await cli_sdk_1.ux.prompt({ type: 'checkbox', name, message: `\n Which workflows would you like to publish ${cli_sdk_1.ux.colors.reset.green('→')}`, choices: ops.map(op => { return { value: op, name: `${op.name} - ${op.description}`, }; }), validate: input => input.length > 0, }); return anwsers.ops; }; } _validateChangelog(input) { if (input === '') return 'You need to provide a publish changelog of your workflow before continuing'; return true; } async run() { const config = await this.isLoggedIn(); try { const { flags, args } = this.parse(Publish); const buildOptions = Object.assign({ nocache: flags.nocache }, Docker_1.PREFER_AMD64); await this.checkDocker(); const teamInstallationExists = await this.services.subscriptionService.getTeamInstallation(config); const opPath = this.resolvePath(args.path); let manifest = await this.getOpsAndWorkflows(opPath); // If op is a pipeline, check job name(s) // to ensure no namespece conflict if (manifest.pipelines) { // check pipeline name against its job names for (let pipeline of manifest.pipelines) { for (let job of pipeline.jobs) { if (job.name === pipeline.name) { throw new CustomErrors_1.InvalidPipelineJobNameOpsYml(job.name); } } } } manifest.ops = (0, OpsYml_1.checkAndApplyLatestSDKVersion)(manifest.ops); if (flags.ops) { const opsToBuild = this.filterOps([...manifest.ops, ...manifest.pipelines, ...manifest.services], flags.ops); if (!teamInstallationExists) { this.eventsWarning(opsToBuild, config.team.name); } let ops = []; for (let op of opsToBuild) { if (await this.checkForExistingVersion(op.name, op.version)) { const updatedVersion = await this.bumpUpVersion(config.team.name, op.name, op.version); op.version = updatedVersion; let manifest = await fs.readFile(path.join(opPath, opConfig_1.OP_FILE), 'utf8'); manifest = manifest.replace(`name: ${op.name}:${op.version}`, `name: ${op.name}:${updatedVersion}`); if (op.type === opConfig_1.PIPELINE_TYPE) { // pipelines need to be converted to ops in order to be build const convertedPipelines = await this.services.opService.convertPipelinesToOps([op], config, opPath); ops.push(...convertedPipelines); } else if (op.type === opConfig_1.SERVICE_TYPE) { // services need to be converted to ops in order to be build const convertedServices = (0, OpsYml_1.convertServicesToOps)([ op, ]); ops.push(...convertedServices); } else { ops.push(op); } } } await this.services.opService.opsBuildLoop(ops, opPath, config, buildOptions); const opsToPublish = this.filterOps([...manifest.ops, ...manifest.pipelines, ...manifest.services], flags.ops); const commandsToPublish = opsToPublish.filter(op => op.type === opConfig_1.COMMAND_TYPE); const pipelinesToPublish = opsToPublish.filter(op => op.type === opConfig_1.PIPELINE_TYPE); const servicesToPublish = opsToPublish.filter(op => op.type === opConfig_1.SERVICE_TYPE); const { pipelineWorkflows, pipelineCommands, } = this.convertPipelinesToWorkflows(pipelinesToPublish, opPath); const serviceOps = (0, OpsYml_1.convertServicesToOps)(servicesToPublish); const commands = commandsToPublish.concat(serviceOps); await this.opsPublishLoop(commands, manifest.version, config, flags.changelog); await this.pipelineCommandsPublishLoop(pipelineCommands, manifest.version, config); await this.workflowsPublishLoop(pipelineWorkflows, manifest.version, config, flags.changelog); } else { const serviceOps = (0, OpsYml_1.convertServicesToOps)(manifest.services); const allOps = [...serviceOps, ...manifest.pipelines, ...manifest.ops]; const ops = await this._selectOption(allOps, 'ops'); for (let op of ops) { const versionedOp = await this.ensureAvailableVersion(opPath, op, buildOptions); if (!teamInstallationExists) { this.eventsWarning([versionedOp], config.team.name); } switch (op.type) { case opConfig_1.SERVICE_TYPE: case opConfig_1.COMMAND: // await this.validateBilling(config, finalCommands[0]) await this.opsPublishLoop([versionedOp], manifest.version, config, flags.changelog); break; case opConfig_1.PIPELINE: const { // Parent Pipeline pipelineWorkflows, // Pipeline Job pipelineCommands, } = this.convertPipelinesToWorkflows([versionedOp], opPath); // Publish Pipeline Jobs await this.pipelineCommandsPublishLoop(pipelineCommands, manifest.version, config); // Publish Parnet Pipeline await this.workflowsPublishLoop(pipelineWorkflows, manifest.version, config, flags.changelog); break; default: this.debug('found neither valid commands or pipelines'); process.exit(1); } } } // await this.services.billingService.updateBilling( // config.team.name, // config.user.username, // config.user.email, // config.tokens.accessToken, // ) } catch (err) { this.debug('%O', err); this.config.runHook('error', { err, accessToken: config.tokens.accessToken, }); } } } exports.default = Publish; Publish.description = 'Publish a workflow to your team.'; Publish.flags = { help: base_1.flags.help({ char: 'h' }), ops: base_1.flags.string({ char: 'o', multiple: true, description: 'Provide the list of workflows that you want to publish.', helpValue: 'workflows', }), changelog: base_1.flags.string({ char: 'c', description: 'Provide a publish changelog', default: '', }), nocache: base_1.flags.boolean({ default: false, description: 'Do not use cache when building the image', }), }; Publish.args = [ { name: 'path', description: 'Path to the workflow you want to publish.', required: true, }, ];