import * as chalk from 'chalk'; import {command, flag, help, namespace, option, param} from 'oo-cli'; 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 AppVersionState = Rivendell.AppVersionState; import {formatError} from '../../lib/formatError'; import {applicableShards} from '../../lib/Shards'; import {TerminalConfirm} from '../../lib/TerminalConfirm'; const UNPUBLISHABLE_STATES = [AppVersionState.RUNNING, AppVersionState.START_FAILED, AppVersionState.STOP_FAILED]; @namespace('directory') export class UnpublishCommand { @flag('f') public force!: boolean; @flag('no-progress') public noProgress!: boolean; @param @help('The App ID and version (e.g. my_app@1.0.0)') public appVersion!: string; @option('a') @help('The availability zone that will be targeted (default: us)') public availability: string = ''; @flag('no-prompt') @help('Use default answer to any prompt question (default is yes, except for destructive operations)') public noPrompt: boolean = false; @command @help('Unpublish an app version') public async unpublish() { 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('@'); let success = true; try { const primaryAppVersion = await Rivendell.fetchAppVersion({appId, version}, this.availability || 'us'); const primaryShardAppState = primaryAppVersion.state as AppVersionState; if (!UNPUBLISHABLE_STATES.includes(primaryShardAppState) && AppVersionState.STOPPED !== primaryShardAppState) { throw new Error(`AppVersion must be in an unpublishable state in the primary shard. ` + `Currently: ${formatVersionState(primaryShardAppState)}`); } const manifest = JSON.parse(primaryAppVersion.manifestJson); const shards = await applicableShards(this.availability, manifest); for (const shard of shards) { const canUnpublishResult = await Rivendell.canUnpublish(appId, version, shard); if (!canUnpublishResult.canPerformAction) { console.error(chalk.red(`Cannot unpublish in availability zone ${shard}:`)); console.error(chalk.red(canUnpublishResult.reason)); return; } } if (!await TerminalConfirm.ask( chalk.red('Unpublishing is not reversible. Are you sure you want to unpublish? [y/n]'), this.noPrompt, true)) { die('Operation cancelled.'); } for (const shard of shards) { console.log(chalk.green(`Unpublishing ${this.appVersion} from ${shard}.`)); try { const appVersion = await Rivendell.fetchAppVersion({appId, version}, shard); const appState = appVersion.state as AppVersionState; if (AppVersionState.STOPPED === appState) { console.log(chalk.yellow(`${this.appVersion} is already unpublished in ${shard}. Skipping...`)); continue; } if (!UNPUBLISHABLE_STATES.includes(appState)) { throw new Error(`AppVersion must be in an unpublishable state. ` + `Currently: ${formatVersionState(appState)}`); } await Rivendell.unpublish(appId, version, shard); console.log(chalk.green(`${this.appVersion} is being unpublished in ${shard}.`)); await this.watchForCompletion({appId, version}, shard); } catch (error: any) { // If it's a bad request error, just log the error message if (error.response && error.response.status === 400) { console.log(chalk.yellow(`Unable to unpublish in shard ${shard}: ${formatError(error)}`)); } else { success = false; console.error(chalk.red(`Error unpublishing in shard ${shard}: ${formatError(error)}`)); if (shards[shards.length - 1] !== shard && !await TerminalConfirm.ask('Continue with other shards?', this.noPrompt, true)) { break; } } } } } catch (e: any) { die(formatError(e)); } if (!success) { die(chalk.red(`Failed to unpublish ${this.appVersion} from all shards.`)); } } private async watchForCompletion(context: AppContext, shard: string) { console.log(chalk.gray(`Watching for unpublish to complete in ${shard}... CTRL+C to stop checking.`)); return new Promise((resolve, reject) => { const spinner = this.noProgress ? null : new TerminalSpinner().start(''); let attempts = 0; const checkStatus = () => { attempts++; Rivendell.fetchAppVersion(context, shard) .then((appVersion) => { const state = appVersion.state as AppVersionState; if (state === AppVersionState.STOPPED) { if (spinner) { spinner.stop(); } console.log(chalk.green(`${context.appId}@${context.version} has been unpublished in ${shard}.`)); 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(); }); } }