import * as chalk from 'chalk'; import {command, flag, help, namespace, param} from 'oo-cli'; import {terminal} from 'terminal-kit'; import {AppContext} from '../../lib/AppContext'; import {die} from '../../lib/die'; import {formatVersionState} from '../../lib/formatVersionState'; import {handleInterrupt} from '../../lib/handleInterrupt'; import {TerminalSpinner} from '../../lib/TerminalSpinner'; import {Rivendell} from '../../lib/Rivendell'; import {formatError} from '../../lib/formatError'; import AppVersionState = Rivendell.AppVersionState; import {applicableShards} from '../../lib/Shards'; import { prerelease, rcompare } from 'semver'; import { UnpublishCommand } from './Unpublish'; import { TerminalConfirm } from '../../lib/TerminalConfirm'; const STOPPED_STATES = [AppVersionState.START_FAILED, AppVersionState.STOPPED, AppVersionState.STOP_FAILED]; const UNPUBLISHABLE_STATES = [AppVersionState.RUNNING, AppVersionState.START_FAILED, AppVersionState.STOP_FAILED]; const PUBLISHABLE_STATES = [ AppVersionState.NEW, AppVersionState.PUBLISHED, AppVersionState.START_FAILED ]; @namespace('directory') export class PublishCommand { @flag('no-progress') public noProgress!: boolean; @param @help('The App ID and version (e.g. my_app@1.0.0)') public appVersion!: string; @flag('no-prompt') @help('Use default answer to any prompt question (default is yes, except for destructive operations)') public noPompt: boolean = false; @command @help('Publish (make available) a specific app version that has been reviewed and approved') public async publish() { handleInterrupt(); if (!/[\w-]+@\d+\.\d+\.\d+(?:-\w+\.\d+)?/.test(this.appVersion)) { die('appVersion is required in the format of app@1.0.0'); } const [appId, version] = this.appVersion.split('@'); console.log(chalk.gray(`Publishing ${this.appVersion} to the directory...`)); let success = true; try { const primaryAppVersion = await Rivendell.fetchAppVersion({appId, version}, 'us'); const manifest = JSON.parse(primaryAppVersion.manifestJson); const shards = await applicableShards('', manifest); for (const shard of shards) { try { const appVersion = await Rivendell.fetchAppVersion({appId, version}, shard); if (appVersion.state as AppVersionState === AppVersionState.RUNNING) { console.log(chalk.green(`Already published in ${shard}. Skipping...`)); continue; } if (!PUBLISHABLE_STATES.includes(appVersion.state as AppVersionState)) { throw new Error(`AppVersion must be in a publishable state. ` + `Currently: ${formatVersionState(appVersion.state as AppVersionState)}`); } await Rivendell.publish(appId, version, shard); console.log(chalk.green(`Success. ${this.appVersion} is being published to ${shard}.`)); const context = {appId, version}; await this.watchForPublishCompletion(context, await this.isStopped(context, shard), shard); // since we are handling the interrupt, disable the handler so we can exit terminal.removeAllListeners('key'); } catch (error: any) { success = false; console.error(chalk.red(`Error publishing in shard ${shard}: ${formatError(error)}`)); if (shards[shards.length - 1] !== shard && !await TerminalConfirm.ask('Continue with other shards?', this.noPompt, true)) { break; } } } const pre = prerelease(version); if (pre && pre[0] !== 'dev') { await this.checkOrphanedDeploys(appId, shards); } } catch (e: any) { die(formatError(e)); } if (!success) { die(chalk.red(`Failed to publish ${this.appVersion} to all shards.`)); } } private async checkOrphanedDeploys(appId: string, currentShards: string[]) { const criteria: Rivendell.AppVersionSearchCriteria = { appId, states: UNPUBLISHABLE_STATES, }; const versions: Rivendell.AppVersion[] = await Rivendell.searchAppVersions(criteria, 'us'); if (versions.length) { versions.sort((x, y) => { return rcompare(x.id.version, y.id.version); }); const undeployVersions: {[key: string]: Rivendell.AppVersion[]} = {}; for (const version of versions) { const appVersion = await Rivendell.fetchAppVersion({appId, version: version.id.version}, 'us'); const shards = await applicableShards('', JSON.parse(appVersion.manifestJson)); const check = shards.filter((x: string) => !currentShards.includes(x)); for (const s of check) { undeployVersions[s] = await Rivendell.searchAppVersions(criteria, s); } } if (Object.keys(undeployVersions).length) { console.log(`You are no longer targeting the availability: ${Object.keys(undeployVersions)}, ` + `do you want to unpublish the following apps:`); console.log(Object.entries(undeployVersions) .map((x) => x[1].map((y) => `* ${y.id.appId}@${y.id.version} in ${x[0]}`).join('\n')).join('\n')); // when noPrompt flag is enabled, default answer is no for destructive operation if (await TerminalConfirm.ask( chalk.red('Do you want to unpublish the listed AppVersions now?'), this.noPompt, false)) { for (const [shard, toUndeploy] of Object.entries(undeployVersions)) { for (const version of toUndeploy) { const unpublish = new UnpublishCommand(); unpublish.appVersion = `${version.id.appId}@${version.id.version}`; unpublish.availability = shard; await unpublish.unpublish(); } } } } } } private async isStopped(context: AppContext, shard: string) { const appVersion = await Rivendell.fetchAppVersion(context, shard); return STOPPED_STATES.includes(appVersion.state as AppVersionState); } private watchForPublishCompletion(context: AppContext, wasStopped: boolean, shard: string) { console.log(chalk.gray(`Watching for publish (${shard}) to complete... CTRL+C to stop checking.`)); return new Promise((resolve, reject) => { const spinner = this.noProgress ? null : new TerminalSpinner().start(''); let attempts = 0; let ignoreStopped = wasStopped; const checkStatus = () => { attempts++; Rivendell.fetchAppVersion(context, shard) .then((appVersion) => { const state = appVersion.state as AppVersionState; if (ignoreStopped && !STOPPED_STATES.includes(state)) { ignoreStopped = false; } if (state === AppVersionState.RUNNING || (!ignoreStopped && STOPPED_STATES.includes(state))) { if (spinner) { spinner.stop(); } if (state === AppVersionState.RUNNING) { console.log(chalk.green(`${context.appId}@${context.version} has been published to ${shard}.`)); } else { die(`Publish to ${shard} failed. ${context.appId}@${context.version} ` + `in ${formatVersionState(state)} state.`); } resolve(); } else { if (attempts > 300) { die('Timed out waiting for completion'); } if (spinner) { spinner.update(`Status: ${formatVersionState(state)}`); setTimeout(checkStatus, 2000); } } }) .catch((e) => { if (spinner) { spinner.stop(); } reject(e); }); }; checkStatus(); }); } }