UNPKG

mira

Version:

NearForm Accelerator for Cloud Native Serverless AWS

697 lines (648 loc) 20.9 kB
import path from 'path' import fs from 'fs' import chalk from 'chalk' import { ParsedArgs } from 'minimist' import { spawn } from 'child_process' import { MiraApp } from './app' import { Account, MiraConfig } from '../config/mira-config' import configWizard from './constructs/config/make-default-config' import { assumeRole } from '../assume-role' import yargs from 'yargs' import Transpiler from '../transpiler' import * as JsonValidation from '../jsonvalidator' import configModule from 'config' import aws from 'aws-sdk' import CloudFormation, { StackEvent } from 'aws-sdk/clients/cloudformation' import ChangeDetector from '../change-detector' import ErrorLogger from '../error-logger' import { quickDeploy, removeAssetDirectories } from './deploy-buckets' import MiraVersion from '../version' type ValidAwsContruct = CloudFormation; const supportedCdkArgs = ['outputs-file', 'require-approval'] /** * @class Responsible for beaming up bits to AWS. Teleportation device not * included. */ export class MiraBootstrap { app: MiraApp; spawn: typeof spawn; args: ParsedArgs; env: string; profile: string; cdkCommand: string; docsifyCommand: string; stackFile: string; errorLogger: ErrorLogger; constructor () { this.cdkCommand = path.join( require.resolve('aws-cdk'), '..', '..', 'bin', 'cdk' ) this.docsifyCommand = path.join( require.resolve('docsify-cli'), '..', '..', 'bin', 'docsify' ) this.app = new MiraApp() this.errorLogger = new ErrorLogger() this.spawn = spawn } /** * Orchestration used for deployment of the given application. This is used whenever developer or CI will try to * deploy application. It is important to keep this function as a single place for application deployment so, development environment * will have the same deployment process as CI owned environments. * * @param undeploy */ async deploy (undeploy = false): Promise<void> { const envConfig = MiraConfig.getEnvironment(this.env) this.env = envConfig.name if (this.args.role) { try { await assumeRole(this.args.role) } catch (error) { console.warn(chalk.red('Error Assuming Role'), error.message) return } } let cmd = 'deploy' if (undeploy) { cmd = 'destroy' } const additionalArgs = [] supportedCdkArgs.forEach(attribute => { if (Object.keys(this.args).includes(attribute)) { additionalArgs.push(`--${attribute}=${this.args[attribute]}`) delete this.args[attribute] } }) if (Object.keys(this.args).includes('stackName')) { additionalArgs.push(this.args.stackName) delete this.args.stackName } if ( Object.prototype.hasOwnProperty.call(this.args, 'dry-run') || Object.prototype.hasOwnProperty.call(this.args, 's3-only') ) { removeAssetDirectories() cmd = 'synth' } const commandOptions = [ this.cdkCommand, cmd, '--app', this.getCDKArgs('app.js'), envConfig.name ? `--env=${envConfig.name}` : '', this.env ? `--profile=${this.getProfile(this.env)}` : '', ...additionalArgs ] const proc = this.spawn('node', commandOptions, { stdio: 'inherit', env: { NODE_ENV: 'dev', ...process.env } }) await new Promise<void>((resolve, reject) => { proc.on('exit', async code => { if (code !== 0) { console.error('Deploy Stack Failed. Dumping Error message') await this.printExtractedNestedStackErrors() reject(code) return } resolve() }) }) if (Object.prototype.hasOwnProperty.call(this.args, 's3-only')) { await quickDeploy() } } /** * Orchestration for `npx mira cicd` command. As an effect, CodePipeline and related services will be deployed together * with permission stacks deployed to the target accounts. */ async deployCi (): Promise<void> { const permissionFilePath = MiraConfig.getPermissionsFilePath() if (!permissionFilePath) { console.error( 'Permissions file path must be specified either in cicd config or as --file parameter.' ) throw new Error( 'Permissions file path must be specified either in cicd config or as --file parameter.' ) } const ciTargetAccounts: Account[] = MiraConfig.getCICDAccounts() let cmd = 'deploy' if (Object.prototype.hasOwnProperty.call(this.args, 'dry-run')) { cmd = 'synth' } for (const account of ciTargetAccounts) { const commandOptions = [ this.cdkCommand + (process.platform === 'win32' ? '.cmd' : ''), cmd, '--app', this.getCDKArgs('app.js', true, account.name), account.name ? `--profile=${this.getProfile(account.name)}` : '' ] console.log( chalk.cyan( `Starting deployment of CI ${account.name} permissions to Account: ${account.env.account} in ${account.env.region} with profile ${account.profile}.` ) ) const proc = this.spawn('node', commandOptions, { stdio: 'inherit', env: { ...process.env, NODE_ENV: account.name } }) await new Promise<void>((resolve, reject) => { proc.on('exit', err => { if (err) { console.error(`Deploy CI Permission failed for account '${account.name}'`) reject(err) return } console.log(chalk.green(`Done deploying CI ${account.name} permissions.`)) resolve() }) }) } console.log( chalk.cyan( `Starting deployment of CI pipeline to Account: ${ MiraConfig.getCICDConfig().account.env.account } in ${MiraConfig.getCICDConfig().account.env.region} with profile ${ MiraConfig.getCICDConfig().account.profile }.` ) ) const commandOptions = [ this.cdkCommand + (process.platform === 'win32' ? '.cmd' : ''), cmd, '--app', this.getCDKArgs('ci-app.js'), `--profile=${this.getProfile('cicd')}` ] const proc = this.spawn('node', commandOptions, { stdio: 'inherit', env: { ...process.env } }) await new Promise<void>((resolve, reject) => { proc.on('exit', err => { if (err) { console.error('Deploy CI pipeline failed') reject(err) return } console.log(chalk.green('Done deploying CI pipeline.')) resolve() }) }) } /** * Runs docsify web server with the mira docs. */ async runDocs (): Promise<void> { const commandOptions = [ this.docsifyCommand + (process.platform === 'win32' ? '.cmd' : ''), 'serve', path.join(__dirname, '..', '..', '..', 'docs') ] const proc = this.spawn('node', commandOptions, { stdio: 'inherit', env: { ...process.env } }) await new Promise<void>((resolve, reject) => { proc.on('exit', err => { if (err) { reject(err) return } console.log(chalk.green('Done building docs.')) resolve() }) }) } /** * TODO: check this functionality together with sample app that supports custom domain. */ async deployDomain (): Promise<void> { console.log('deploying domain') const envConfig = MiraConfig.getEnvironment(this.env) let cmd = 'deploy' if (Object.prototype.hasOwnProperty.call(this.args, 'dry-run')) { cmd = 'synth' } const commandOptions = [ this.cdkCommand + (process.platform === 'win32' ? '.cmd' : ''), cmd, '--app', this.getCDKArgs('domain.js'), `--env=${envConfig.name}`, `--profile=${this.getProfile(this.env)}` ] const proc = this.spawn('node', commandOptions, { stdio: 'inherit', env: { ...process.env } }) await new Promise<void>((resolve, reject) => { proc.on('exit', err => { if (err) { reject(err) return } console.log(chalk.green('Done deploying domain.')) resolve() }) }) } /** * Gets the arguments parsed by the app file provided for the CDK CLI. * @param filename - main application file. * @param isCi - when "npx mira cicd" command is executed permissions file path is taken from the config.file, NOT from the CLI param. * @param env - name of current target environment where the stack with role is going to be deployed. */ getCDKArgs (filename: string, isCi = false, env?: string): string { const resultedEnv = this.env || env const q = process.platform === 'win32' ? '"' : "'" let appPath = path.resolve(__dirname, filename) if (fs.existsSync('node_modules/mira')) { if (fs.lstatSync('node_modules/mira/dist').isSymbolicLink()) { // Mira has been locally linked. try { fs.mkdirSync('node_modules/mira-bootstrap') } catch (e) { // NOOP } fs.writeFileSync( `node_modules/mira-bootstrap/bootstrap-${filename}`, `require('mira/dist/src/cdk/${filename}')`, 'utf8' ) appPath = `node_modules/mira-bootstrap/bootstrap-${filename}` } } let appArg = `${q}node --preserve-symlinks "${appPath}" ` // Still inside the quotes, explode the args. // appArg += this.getArgs().join(' ') appArg += !isCi && this.stackFile ? ` --file=${this.stackFile}` : '' appArg += resultedEnv ? ` --env=${resultedEnv}` : '' appArg += isCi ? ` --file=${MiraConfig.getPermissionsFilePath()}` : '' appArg += q // End quote. return appArg } /** * Gets the arguments for this stack. It has built-in support for osx/win support. */ getArgs (): string[] { // eslint-disable-next-line no-useless-rename, @typescript-eslint/no-unused-vars const { _: _, ...args } = this.args const newArgs: string[] = [] for (const key of Object.keys(args)) { if (args[key]?.includes && args[key].includes(' ')) { const q = process.platform !== 'win32' ? "'" : '"' args[key] = q + args[key] + q } else if (typeof args[key] === 'boolean') { newArgs.push(`--${key}`) continue } newArgs.push(`--${key}`, args[key]) } return newArgs } /** * Gets the profile given the env. */ getProfile (environment: string): string | void { // if we are in Codebuild environment, return 'client' which is the one set by assume-role if (process.env.CODEBUILD_CI) { return 'client' } const envConfig = MiraConfig.getEnvironment(environment) if (envConfig) { return envConfig.profile } if (this.profile) { return this.profile } } /** * Verifies wether files provided in the CLI exists. */ async areStackFilesValid (): Promise<boolean> { let isValid = true for (const stackName of [this.stackFile]) { if (!(await this.app.getStack(stackName))) { console.warn(chalk.yellow('Stack Not Found:'), stackName) isValid = false } } return isValid } /** * Function being called when CLI is invoked. */ async initialize (): Promise<void> { MiraVersion.checkApplicationCDKVersion() this.args = this.showHelp() this.env = this.args.env this.profile = this.args.profile if (Object.keys(this.args).includes('env')) { delete this.args.env } const rawConfig = configModule.util.toObject() const cmd = this.args._[0] const cd = new ChangeDetector(process.cwd()) const filesChanged = await cd.run() let transpiledStackFile switch (cmd) { case 'domain': await this.deployDomain() break case 'init': await configWizard() break case 'deploy': if (!this.args.file) { console.warn( chalk.red('Error Initializing'), 'Must supply' + ' a --file=<stackFile> argument.' ) return } if (!JsonValidation.validateConfig(rawConfig)) { console.warn(chalk.red('Error Initializing'), 'Invalid config file.') return } this.stackFile = this.args.file // Check for file changes if (filesChanged || this.args.force) { if (!this.args.file) { console.warn( chalk.red('Error Initializing'), 'Must supply' + ' a --file=<stackFile> argument.' ) return } transpiledStackFile = await this.transpile() if (transpiledStackFile) { this.stackFile = transpiledStackFile // take a new snapshot because file changed. await cd.takeSnapshot(cd.defaultSnapshotFilePath) } if (await this.areStackFilesValid()) { console.info( chalk.cyan('Deploying Stack:'), `(via ${chalk.grey(this.stackFile)})` ) await this.deploy() } } else { console.info( chalk.yellow('No file changed after last deploy. Skipping.') ) } break case 'undeploy': if (!this.args.file) { console.warn( chalk.red('Error Initializing'), 'Must supply' + ' a --file=<stackFile> argument.' ) return } this.stackFile = this.args.file transpiledStackFile = await this.transpile() if (transpiledStackFile) { this.stackFile = transpiledStackFile } if (await this.areStackFilesValid()) { console.info( chalk.cyan('Undeploying Stack:'), `(via ${chalk.grey(this.stackFile)})` ) await this.undeploy() } else { console.info( 'If you want to undeploy a stack not contained' + ' in your local filesystem, please use the AWS console' + ' directly.' ) } break case 'cicd': if (!JsonValidation.validateConfig(rawConfig)) { console.warn(chalk.red('Error Initializing'), 'Invalid config file.') return } await this.deployCi() break case 'docs': this.runDocs() break case 'clean': await this.errorLogger.cleanMessages() break default: this.showHelp() } } /** * Shows help for the CDK. */ showHelp (): ParsedArgs { return yargs // eslint-disable-line .scriptName('npx mira') .usage('Usage: npx mira COMMAND') .option('profile', { type: 'string', alias: 'p', desc: 'AWS profile name used for AWS CLI' }) .command('deploy', 'Deploys given stack', yargs => yargs.option('file', { type: 'string', alias: 'f', desc: 'REQUIRED: Path to your stack file', requiresArg: true }) ) .command('undeploy', 'Un-Deploys given stack', yargs => yargs.option('file', { type: 'string', alias: 'f', desc: 'REQUIRED: Path to your stack file', requiresArg: true }) ) .command('cicd', 'Deploys CI/CD pipeline', yargs => yargs .option('file', { type: 'string', aliast: 'f', desc: 'Path to permissions stack file.' }) .option('envVar', { type: 'string', desc: 'Environment variable passed into the code build' }) ) .command('docs', 'Starts local web server with documentation') .command('clean', 'Removes error log files') .command('domain', 'Deploys Domain Manager') .help() .demandCommand().argv as unknown as ParsedArgs } /** * Undeploys a stack. This calls deploy with the undeploy parameter. The * only reason to do this is that both calls share almost identical code. */ async undeploy (): Promise<void> { return await this.deploy(true) } /** * Changes context to use dev config if available, and runs passed function. * Typical usecase for this function is to set Dev environment in the context of Mira executable. * In case of CDK executions 'dev' is set as NODE_ENV during spawn. * @param fn * @param params */ useDevConfig<R, Z> (fn: (args: R) => Z, params: [R]): Z { const tmpEnv = process.env.NODE_ENV || 'default' process.env.NODE_ENV = 'dev' const rsp = fn.apply(this, params) process.env.NODE_ENV = tmpEnv return rsp } getServiceStackName (account: Account): string { const tmpConfig = configModule.util.loadFileConfigs( path.join(process.cwd(), 'config') ) return `${MiraApp.getBaseStackNameFromParams( tmpConfig.app.prefix, tmpConfig.app.name, 'Service' )}-${account.name}` } static getServiceStackName (account: Account): string { const tmpConfig = configModule.util.loadFileConfigs( path.join(process.cwd(), 'config') ) return `${MiraApp.getBaseStackNameFromParams( tmpConfig.app.prefix, tmpConfig.app.name, 'Service' )}-${account.name}` } getAwsSdkConstruct (construct: string, account: Account): ValidAwsContruct { const credentials = new aws.SharedIniFileCredentials({ profile: this.getProfile(this.env) || '' }) aws.config.credentials = credentials // eslint-disable-next-line // @ts-ignore return new aws[construct]({ region: account.env.region }) } async getFirstFailedNestedStackName ( account: Account, stackName: string ): Promise<string | undefined> { const cloudformation = this.getAwsSdkConstruct('CloudFormation', account) const events = await cloudformation .describeStackEvents({ StackName: stackName }) .promise() return events.StackEvents?.filter( (event: StackEvent) => event.ResourceStatus === 'UPDATE_FAILED' || event.ResourceStatus === 'CREATE_FAILED' )[0]?.PhysicalResourceId } async extractNestedStackError (): Promise<StackEvent[]> { const account: Account = MiraConfig.getEnvironment(this.env) const stackName = this.useDevConfig(this.getServiceStackName, [account]) // Environment variable required to parse ~/.aws/config file with profiles. process.env.AWS_SDK_LOAD_CONFIG = '1' let events try { const nestedStackName = await this.getFirstFailedNestedStackName( account, stackName ) const cloudformation = this.getAwsSdkConstruct( 'CloudFormation', account ) as CloudFormation events = await cloudformation .describeStackEvents({ StackName: nestedStackName }) .promise() } catch (e) { console.log( chalk.red( 'Error, while getting error message from cloudformation. Seems something is wrong with your configuration.' ) ) } const output = events?.StackEvents?.filter( (event: StackEvent) => event.ResourceStatus === 'UPDATE_FAILED' || event.ResourceStatus === 'CREATE_FAILED' ) return output || [] } filterStackErrorMessages (errors: StackEvent[]): StackEvent[] { const output = errors.filter(error => { return error.ResourceStatusReason !== 'Resource creation cancelled' }) return output } formatNestedStackError (item: StackEvent): string { return `\n* ${item.ResourceStatus} - ${item.LogicalResourceId}\nReason: ${item.ResourceStatusReason}\nTime: ${item.Timestamp}\n` } async printExtractedNestedStackErrors (): Promise<void> { const printCarets = (nb: number): string => { return '^'.repeat(nb) } const failedResources = await this.extractNestedStackError() if (Array.isArray(failedResources)) { console.log( chalk.red( '\n\nYour app failed deploying, one of your nested stacks have failed to create or update resources. See the list of failed resources below:' ) ) const filteredMessages = this.filterStackErrorMessages(failedResources) filteredMessages.map(errorMessage => { console.log(chalk.red(this.formatNestedStackError(errorMessage))) }) this.errorLogger.flushMessages( filteredMessages.map(m => this.formatNestedStackError(m)) ) console.log( chalk.red( `\n\n${printCarets( 100 )}\nAnalyze the list above, to find why your stack failed deployment.` ) ) } } async transpile (): Promise<string | undefined> { if (this.stackFile.match(/.ts$/)) { const T = new Transpiler(this.stackFile) const newFile = await T.run() return newFile } } }