From 478c17a643401d4342932a0c379abe199239cddc Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Tue, 6 Jul 2021 20:04:06 -0700 Subject: [PATCH 01/26] enhanced amplify status --- packages/amplify-cli-core/src/cliViewAPI.ts | 28 ++ packages/amplify-cli-core/src/index.ts | 4 + packages/amplify-cli/src/commands/status.ts | 9 +- .../amplify-cli/src/domain/amplify-toolkit.ts | 8 + .../amplify-helpers/resource-status-diff.ts | 352 ++++++++++++++++++ .../amplify-helpers/resource-status.ts | 343 ++++++++++++----- .../extensions/amplify-helpers/yaml-cfn.ts | 59 +++ packages/amplify-cli/src/index.ts | 19 +- 8 files changed, 729 insertions(+), 93 deletions(-) create mode 100644 packages/amplify-cli-core/src/cliViewAPI.ts create mode 100644 packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts create mode 100644 packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts new file mode 100644 index 00000000000..a82142504d2 --- /dev/null +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -0,0 +1,28 @@ +//Use this file to store all types used between the CLI commands and the view/display functions +// CLI=>(command-handler)==[CLI-View-API]=>(ux-handler/report-handler)=>output-stream + +export interface CLIParams { + cliCommand : string; + cliOptions : {[key:string] : any}; +} +//Resource Table filter and display params (params used for summary/display view of resource table) +export class ViewResourceTableParams { + public command : string; + public verbose : boolean; //display table in verbose mode + public categoryList : string[]|[] //categories to display + public filteredResourceList : any //resources to *not* display - TBD define union of valid types + getCategoryFromCLIOptions( cliOptions : object ){ + if ( cliOptions ){ + return Object.keys(cliOptions).filter( key => (key != 'verbose') && (key !== 'yes') ) + } else { + return []; + } + } + + public constructor( cliParams : CLIParams ){ + this.command = cliParams.cliCommand; + this.verbose = (cliParams.cliOptions?.verbose === true ); + this.categoryList = this.getCategoryFromCLIOptions( cliParams.cliOptions ); + this.filteredResourceList = []; //TBD - add support to provide resources + } +} \ No newline at end of file diff --git a/packages/amplify-cli-core/src/index.ts b/packages/amplify-cli-core/src/index.ts index 351c80ca04e..28a826258f1 100644 --- a/packages/amplify-cli-core/src/index.ts +++ b/packages/amplify-cli-core/src/index.ts @@ -1,3 +1,4 @@ +import { ViewResourceTableParams, CLIParams } from './cliViewAPI'; import { ServiceSelection } from './serviceSelection'; export * from './cfnUtilities'; @@ -21,6 +22,7 @@ export * from './utils'; export * from './banner-message'; export * from './cliGetCategories'; export * from './cliRemoveResourcePrompt'; +export * from "./cliViewAPI"; // Temporary types until we can finish full type definition across the whole CLI @@ -164,6 +166,7 @@ export interface AmplifyProjectConfig { export type $TSCopyJob = any; + // Temporary interface until Context refactor interface AmplifyToolkit { confirmPrompt: (prompt: string, defaultValue?: boolean) => Promise; @@ -216,6 +219,7 @@ interface AmplifyToolkit { showHelp: (header: string, commands: { name: string; description: string }[]) => $TSAny; showHelpfulProviderLinks: () => $TSAny; showResourceTable: () => $TSAny; + showStatusTable:( resourceTableParams : ViewResourceTableParams )=> $TSAny; //Enhanced Status with CFN-Diff serviceSelectionPrompt: ( context: $TSContext, category: string, diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index 36487b7473c..7bf835b0fe8 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -1,10 +1,15 @@ +import { ViewResourceTableParams, CLIParams } from "amplify-cli-core/lib/cliViewAPI"; + + export const run = async context => { - await context.amplify.showResourceTable(); + const cliParams:CLIParams = { cliCommand : context.input.command, + cliOptions : context.input.options } + await context.amplify.showStatusTable( new ViewResourceTableParams( cliParams ) ); await context.amplify.showHelpfulProviderLinks(context); await showAmplifyConsoleHostingStatus(context); }; -async function showAmplifyConsoleHostingStatus(context) { +async function showAmplifyConsoleHostingStatus( context) { const pluginInfo = context.amplify.getCategoryPluginInfo(context, 'hosting', 'amplifyhosting'); if (pluginInfo && pluginInfo.packageLocation) { const { status } = require(pluginInfo.packageLocation); diff --git a/packages/amplify-cli/src/domain/amplify-toolkit.ts b/packages/amplify-cli/src/domain/amplify-toolkit.ts index 53e8a2d1b76..55f1b41b753 100644 --- a/packages/amplify-cli/src/domain/amplify-toolkit.ts +++ b/packages/amplify-cli/src/domain/amplify-toolkit.ts @@ -41,6 +41,7 @@ export class AmplifyToolkit { private _showHelp: any; private _showHelpfulProviderLinks: any; private _showResourceTable: any; + private _showStatusTable: any; private _serviceSelectionPrompt: any; private _updateProjectConfig: any; private _updateamplifyMetaAfterResourceUpdate: any; @@ -258,6 +259,13 @@ export class AmplifyToolkit { this._showResourceTable || require(path.join(this._amplifyHelpersDirPath, 'resource-status')).showResourceTable; return this._showResourceTable; } + + get showStatusTable(): any { + this._showStatusTable = + this._showStatusTable || require(path.join(this._amplifyHelpersDirPath, 'resource-status')).showStatusTable; + return this._showStatusTable; + } + get serviceSelectionPrompt(): any { this._serviceSelectionPrompt = this._serviceSelectionPrompt || require(path.join(this._amplifyHelpersDirPath, 'service-select-prompt')).serviceSelectionPrompt; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts new file mode 100644 index 00000000000..0030278d7d9 --- /dev/null +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -0,0 +1,352 @@ + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as cfnDiff from '@aws-cdk/cloudformation-diff'; +import * as yaml_cfn from './yaml-cfn'; +import * as cxapi from '@aws-cdk/cx-api'; +import { print } from './print'; +import { pathManager } from 'amplify-cli-core'; +import chalk from 'chalk'; + +const CategoryTypes = { + PROVIDERS : "providers", + API : "api", + AUTH : "auth", + STORAGE : "storage", + FUNCTION : "function", + ANALYTICS : "analytics" +} + +const CategoryProviders = { + CLOUDFORMATION : "cloudformation", +} + +interface StackMutationInfo { + label : String; + consoleStyle : (string)=>string ; + icon : String; +} +interface StackMutationType { + CREATE : StackMutationInfo, + UPDATE : StackMutationInfo, + DELETE : StackMutationInfo, + IMPORT : StackMutationInfo, + UNLINK : StackMutationInfo, + NOCHANGE : StackMutationInfo, +} +export const stackMutationType : StackMutationType = { + CREATE : { + label : "Create", + consoleStyle : chalk.green.bold, + icon : "[+]" + }, + UPDATE : { + label : "Update", + consoleStyle : chalk.yellow.bold, + icon : "[~]" + }, + + DELETE : { + label: "Delete", + consoleStyle : chalk.red.bold, + icon : "[-]" + }, + + IMPORT : { + label: "Import", + consoleStyle : chalk.blue.bold, + icon : `[\u21E9]` + }, + + UNLINK : { + label : "Unlink", + consoleStyle : chalk.red.bold, + icon : `[\u2BFB]` + }, + NOCHANGE : { + label : "No Change", + consoleStyle : chalk.grey, + icon : `[ ]` + } + +} + +//TBD: move this to a class +//Maps File extension to deserializer functions +const InputFileExtensionDeserializers = { + json : JSON.parse, + yaml : yaml_cfn.deserialize, + yml : yaml_cfn.deserialize +} + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} +interface ResourcePaths { + cloudBuildTemplateFile : string; //cloud file path after transformation + localBuildTemplateFile : string; //file path + cloudTemplateFile : string; + localTemplateFile : string; + localPreBuildTemplateFile : string; + cloudPreBuildTemplateFile : string; + +} + +export class ResourceDiff { + resourceName: string; + category : string; + provider : string; + resourceFiles : ResourcePaths; + localBackendDir : string; + cloudBackendDir : string; + localTemplate : { + [key: string]: any; + }; + cloudTemplate : { + [key: string]: any; + }; + + constructor( category, resourceName, provider ){ + this.localBackendDir = pathManager.getBackendDirPath(); + this.cloudBackendDir = pathManager.getCurrentCloudBackendDirPath(); + this.resourceName = resourceName; + this.category = category; + this.provider = this.normalizeProviderForFileNames(provider); + this.localTemplate = {}; //requires file-access, hence loaded from async methods + this.cloudTemplate = {}; //requires file-access, hence loaded from async methods + //Note: All file names include full-path but no extension.Extension will be added later. + this.resourceFiles = { + localTemplateFile : path.normalize(path.join(this.localBackendDir, category, resourceName)), + cloudTemplateFile : path.normalize(path.join(this.cloudBackendDir, category, resourceName)), + //Used for API category, since cloudformation is overridden by user generated changes + localBuildTemplateFile : path.normalize(path.join(this.localBackendDir, category, resourceName, 'build',`${this.provider}-template`)), + cloudBuildTemplateFile : path.normalize(path.join(this.cloudBackendDir, category, resourceName, 'build', `${this.provider}-template`)), + //Use for non-API category like storage, auth + localPreBuildTemplateFile: path.normalize(path.join(this.localBackendDir, category, resourceName, this.getResourceProviderFileName(resourceName, this.provider))), + cloudPreBuildTemplateFile: path.normalize(path.join(this.cloudBackendDir , category, resourceName, this.getResourceProviderFileName(resourceName, this.provider))), + } + //console.log("SACPC:ResourceDiff: ", this.resourceFiles); + } + + normalizeProviderForFileNames(provider:string){ + //file-names are currently standardized around "cloudformation" not awscloudformation. + if ( provider === "awscloudformation"){ + return CategoryProviders.CLOUDFORMATION + } else { + return provider; + } + } + + calculateDiffTemplate = async () => { + const resourceTemplatePaths = await this.getResourceFilePaths() + //load resource template objects + this.localTemplate = await this.loadCloudFormationTemplate(resourceTemplatePaths.localTemplatePath) + this.cloudTemplate = await this.loadCloudFormationTemplate(resourceTemplatePaths.cloudTemplatePath); + const diff = cfnDiff.diffTemplate(this.cloudTemplate, this.localTemplate ); + return diff; + } + + isCategory( categoryType ){ + return this.category.toLowerCase() == categoryType + } + + getResourceFilePaths = async() => { + if ( this.isCategory(CategoryTypes.API) ){ + //API artifacts are stored in build folder for cloud resources + //SACPCTBD!!: This should not rely on the presence or absence of a file, it should be based on the context state. + //e.g user-added, amplify-built-not-deployed, amplify-deployed-failed, amplify-deployed-success + return { + localTemplatePath : (checkExist(this.resourceFiles.localBuildTemplateFile))?this.resourceFiles.localBuildTemplateFile: this.resourceFiles.localPreBuildTemplateFile , + cloudTemplatePath : (checkExist(this.resourceFiles.cloudBuildTemplateFile))?this.resourceFiles.cloudBuildTemplateFile: this.resourceFiles.cloudPreBuildTemplateFile + } + } + return { + localTemplatePath: this.resourceFiles.localPreBuildTemplateFile , + cloudTemplatePath: this.resourceFiles.cloudPreBuildTemplateFile + } + } + + printResourceDetailStatus = async ( mutationInfo : StackMutationInfo ) => { + const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; + const diff = await this.calculateDiffTemplate(); + //display template diff to console + //print.info(`[\u27A5] ${vaporStyle("Stack: ")} ${capitalize(this.category)}/${this.resourceName} : ${header}`); + print.info(`${vaporStyle(`[\u27A5] Resource Stack: ${capitalize(this.category)}/${this.resourceName}`)} : ${header}`); + const diffCount = this.printStackDiff( diff ); + if ( diffCount === 0 ){ + console.log("No changes ") + } + } + + normalizeAWSResourceName( cfnType ){ + let parts = cfnType.split("::"); + parts.shift(); //remove "AWS::" + return parts.join("::") + } + + normalizeCdkChangeImpact( cdkChangeImpact : cfnDiff.ResourceImpact ){ + switch (cdkChangeImpact) { + case cfnDiff.ResourceImpact.MAY_REPLACE: + return chalk.italic(chalk.yellow('may be replaced')); + case cfnDiff.ResourceImpact.WILL_REPLACE: + return chalk.italic(chalk.bold(chalk.red('replace'))); + case cfnDiff.ResourceImpact.WILL_DESTROY: + return chalk.italic(chalk.bold(chalk.red('destroy'))); + case cfnDiff.ResourceImpact.WILL_ORPHAN: + return chalk.italic(chalk.yellow('orphan')); + case cfnDiff.ResourceImpact.WILL_UPDATE: + case cfnDiff.ResourceImpact.WILL_CREATE: + case cfnDiff.ResourceImpact.NO_CHANGE: + return ''; // no extra info is gained here + } + } + + getActionStyle( changeType:StackMutationInfo, valueType ){ + return ` ${changeType.consoleStyle(changeType.icon)} ${this.normalizeAWSResourceName(valueType)}` + } + + getUpdateImpactStyle( summaryStringArray ){ + return summaryStringArray.join(`\n \u2514\u2501 `) + } + + constructChangeSummary( change ){ + let action : string; + let propChangeSummary : string[] = []; + if ( change.isAddition ){ + action = this.getActionStyle(stackMutationType.CREATE, change.newValue.Type); + } else if (change.isRemoval){ + action = this.getActionStyle(stackMutationType.DELETE, change.oldValue.Type); + } else { + //Its an update + action = this.getActionStyle(stackMutationType.UPDATE, change.newValue.Type); + Object.keys(change.propertyDiffs).map( propType => { + const changeData = change.propertyDiffs[propType]; + if ( changeData.isDifferent ) { + const summary = `${propType} ${this.normalizeCdkChangeImpact(changeData.changeImpact)}`; + propChangeSummary.push(summary) + } + }) + } + if ( propChangeSummary.length > 0 ){ + const summaryString = this.getUpdateImpactStyle(propChangeSummary) + return `${action} ${summaryString}` + } else { + return action + } + } + + getDiffSummary = async () => { + const templateDiff = await this.calculateDiffTemplate(); + const results : string[] = []; + templateDiff.resources.forEachDifference( (logicalId, change)=> { + results.push( this.constructChangeSummary( change )); + }); + if ( results.length > 0){ + return results.join("\n"); + } else { + return chalk.grey("no change"); + } + } + + printResourceSummaryStatus = async ( mutationInfo : StackMutationInfo ) => { + const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; + const templateDiff = await this.calculateDiffTemplate(); + //display template diff to console + print.info(`[\u27A5] ${vaporStyle("Stack: ")} ${capitalize(this.category)}/${this.resourceName} : ${header}`); + templateDiff.resources.forEachDifference( (logicalId, change)=> { + console.log(this.constructChangeSummary( change ) ); + }) + } + + printStackDiff = ( templateDiff, stream?: cfnDiff.FormatStream ) =>{ + // filter out 'AWS::CDK::Metadata' resources from the template + if (templateDiff.resources ) { + templateDiff.resources = templateDiff.resources.filter(change => { + if (!change) { return true; } + if (change.newResourceType === 'AWS::CDK::Metadata') { return false; } + if (change.oldResourceType === 'AWS::CDK::Metadata') { return false; } + return true; + }); + } + if (!templateDiff.isEmpty) { + cfnDiff.formatDifferences(stream || process.stderr, templateDiff); + } + return templateDiff.differenceCount; + } + + + getResourceProviderFileName( resourceName : string, providerType : string ){ + //resourceName is the name of an instantiated category type e.g name of your s3 bucket + //providerType is the name of the cloud infrastructure provider e.g cloudformation/terraform + return `${resourceName}-${providerType}-template` + } + + loadStructuredFile = async(fileName: string, deserializeFunction) => { + const contents = await fs.readFile(fileName, { encoding: 'utf-8' }); + return deserializeFunction(contents); + } + + loadCloudFormationTemplate = async( filePath ) => { + try { + //Load yaml or yml or json files + let providerObject = {}; + const inputFileExtensions = Object.keys(InputFileExtensionDeserializers) + for( let i = 0 ; i < inputFileExtensions.length ; i++ ){ + if ( fs.existsSync(`${filePath}.${inputFileExtensions[i]}`) ){ + providerObject = await this.loadStructuredFile(`${filePath}.${inputFileExtensions[i]}`, //Filename with extension + InputFileExtensionDeserializers[inputFileExtensions[i]] //Deserialization function + ); + return providerObject; + } + } + return providerObject; + } catch (e) { + //No resource file found + console.log(e); + throw e; + } + } + +} + +function checkExist( filePath ){ + const inputTypes = [ 'json', 'yaml', 'yml'] ; //check for existence of any one of the extensions. + for( let i = 0 ; i < inputTypes.length ; i++ ){ + if ( fs.existsSync(`${filePath}.${inputTypes[i]}`) ){ + return true; + } + } + return false; +} + +//Interface to store template-diffs for C-R-U resource diffs +export interface IResourceDiffCollection { + updatedDiff : ResourceDiff[]|[], + deletedDiff : ResourceDiff[]|[], + createdDiff : ResourceDiff[]|[] +} + +//Interface to store resource status for each category +export interface ICategoryStatusCollection { + resourcesToBeCreated: any[], + resourcesToBeUpdated: any[], + resourcesToBeDeleted: any[], + resourcesToBeSynced: any[], + allResources:any[], + tagsUpdated: false +} + +//const vaporStyle = chalk.hex('#8be8fd').bgHex('#282a36'); +const vaporStyle = chalk.bgRgb(15, 100, 204) + + +export async function CollateResourceDiffs( resources , mutationInfo : StackMutationInfo /* create/update/delete */ ){ + const provider = CategoryProviders.CLOUDFORMATION; + let resourceDiffs : ResourceDiff[] = []; + for await (let resource of resources) { + resourceDiffs.push( new ResourceDiff( resource.category, resource.resourceName, provider ) ); + } + return resourceDiffs; + } + + diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index 2ece49a12d7..7b9033361ca 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -8,14 +8,16 @@ import { getEnvInfo } from './get-env-info'; import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; import { ServiceName as FunctionServiceName, hashLayerResource } from 'amplify-category-function'; import { removeGetUserEndpoints } from '../amplify-helpers/remove-pinpoint-policy'; -import { pathManager, stateManager, $TSMeta, $TSAny, NotInitializedError } from 'amplify-cli-core'; +import { pathManager, stateManager, $TSMeta, $TSAny, NotInitializedError, ViewResourceTableParams } from 'amplify-cli-core'; +import * as resourceStatus from './resource-status-diff'; +import { ResourceDiff, IResourceDiffCollection, ICategoryStatusCollection } from './resource-status-diff'; + async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPushTimeStamp, hashFunction) { // Pushing the resource for the first time hence no lastPushTimeStamp if (!lastPushTimeStamp) { return false; } - const localBackendDir = path.normalize(path.join(pathManager.getBackendDirPath(), category, resourceName)); const cloudBackendDir = path.normalize(path.join(pathManager.getCurrentCloudBackendDirPath(), category, resourceName)); @@ -256,11 +258,9 @@ async function getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category if (category !== undefined && resourceName !== undefined) { resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); } - if (category !== undefined && !resourceName) { resources = resources.filter(resource => resource.category === category); } - return resources; } @@ -356,6 +356,7 @@ async function asyncForEach(array, callback) { } export async function getResourceStatus(category?, resourceName?, providerName?, filteredResources?) { + const amplifyProjectInitStatus = getCloudInitStatus(); let amplifyMeta: $TSAny; let currentAmplifyMeta: $TSMeta = {}; @@ -373,7 +374,6 @@ export async function getResourceStatus(category?, resourceName?, providerName?, let resourcesToBeUpdated: any = await getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); let resourcesToBeSynced: any = getResourcesToBeSynced(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); let resourcesToBeDeleted: any = getResourcesToBeDeleted(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); - let allResources: any = getAllResources(amplifyMeta, category, resourceName, filteredResources); resourcesToBeCreated = resourcesToBeCreated.filter(resource => resource.category !== 'provider'); @@ -398,109 +398,272 @@ export async function getResourceStatus(category?, resourceName?, providerName?, }; } -export async function showResourceTable(category, resourceName, filteredResources) { - const amplifyProjectInitStatus = getCloudInitStatus(); - if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { - const { envName } = getEnvInfo(); +async function getResourceDiffs ( resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated) { + const result : IResourceDiffCollection = { + updatedDiff : await resourceStatus.CollateResourceDiffs( resourcesToBeUpdated , resourceStatus.stackMutationType.UPDATE), + deletedDiff : await resourceStatus.CollateResourceDiffs( resourcesToBeDeleted , resourceStatus.stackMutationType.DELETE), + createdDiff : await resourceStatus.CollateResourceDiffs( resourcesToBeCreated , resourceStatus.stackMutationType.CREATE) + } + return result; +} - print.info(''); - print.info(`${chalk.green('Current Environment')}: ${envName}`); - print.info(''); - } +function getSummaryTableData({ resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated, + resourcesToBeSynced, + allResources } ){ + + let noChangeResources = _.differenceWith( + allResources, + resourcesToBeCreated.concat(resourcesToBeUpdated).concat(resourcesToBeSynced), + _.isEqual, + ); + noChangeResources = noChangeResources.filter(resource => resource.category !== 'providers'); + + const createOperationLabel = 'Create'; + const updateOperationLabel = 'Update'; + const deleteOperationLabel = 'Delete'; + const importOperationLabel = 'Import'; + const unlinkOperationLabel = 'Unlink'; + const noOperationLabel = 'No Change'; + const tableOptions = [['Category', 'Resource name', 'Operation', 'Provider plugin']]; + + for (let i = 0; i < resourcesToBeCreated.length; ++i) { + tableOptions.push([ + capitalize(resourcesToBeCreated[i].category), + resourcesToBeCreated[i].resourceName, + createOperationLabel, + resourcesToBeCreated[i].providerPlugin, + ]); + } - const { - resourcesToBeCreated, - resourcesToBeUpdated, - resourcesToBeDeleted, - resourcesToBeSynced, - allResources, - tagsUpdated, - } = await getResourceStatus(category, resourceName, undefined, filteredResources); + for (let i = 0; i < resourcesToBeUpdated.length; ++i) { + tableOptions.push([ + capitalize(resourcesToBeUpdated[i].category), + resourcesToBeUpdated[i].resourceName, + updateOperationLabel, + resourcesToBeUpdated[i].providerPlugin, + ]); + } - let noChangeResources = _.differenceWith( - allResources, - resourcesToBeCreated.concat(resourcesToBeUpdated).concat(resourcesToBeSynced), - _.isEqual, - ); - noChangeResources = noChangeResources.filter(resource => resource.category !== 'providers'); - - const createOperationLabel = 'Create'; - const updateOperationLabel = 'Update'; - const deleteOperationLabel = 'Delete'; - const importOperationLabel = 'Import'; - const unlinkOperationLabel = 'Unlink'; - const noOperationLabel = 'No Change'; - const tableOptions = [['Category', 'Resource name', 'Operation', 'Provider plugin']]; - - for (let i = 0; i < resourcesToBeCreated.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeCreated[i].category), - resourcesToBeCreated[i].resourceName, - createOperationLabel, - resourcesToBeCreated[i].providerPlugin, - ]); - } + for (let i = 0; i < resourcesToBeSynced.length; ++i) { + let operation; + + switch (resourcesToBeSynced[i].sync) { + case 'import': + operation = importOperationLabel; + break; + case 'unlink': + operation = unlinkOperationLabel; + break; + default: + // including refresh + operation = noOperationLabel; + break; + } - for (let i = 0; i < resourcesToBeUpdated.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeUpdated[i].category), - resourcesToBeUpdated[i].resourceName, - updateOperationLabel, - resourcesToBeUpdated[i].providerPlugin, - ]); - } + tableOptions.push([ + capitalize(resourcesToBeSynced[i].category), + resourcesToBeSynced[i].resourceName, + operation /*syncOperationLabel*/, + resourcesToBeSynced[i].providerPlugin, + ]); + } - for (let i = 0; i < resourcesToBeSynced.length; ++i) { - let operation; + for (let i = 0; i < resourcesToBeDeleted.length; ++i) { + tableOptions.push([ + capitalize(resourcesToBeDeleted[i].category), + resourcesToBeDeleted[i].resourceName, + deleteOperationLabel, + resourcesToBeDeleted[i].providerPlugin, + ]); + } - switch (resourcesToBeSynced[i].sync) { - case 'import': - operation = importOperationLabel; - break; - case 'unlink': - operation = unlinkOperationLabel; - break; - default: - // including refresh - operation = noOperationLabel; - break; - } + for (let i = 0; i < noChangeResources.length; ++i) { + tableOptions.push([ + capitalize(noChangeResources[i].category), + noChangeResources[i].resourceName, + noOperationLabel, + noChangeResources[i].providerPlugin, + ]); + } + return tableOptions; +} - tableOptions.push([ - capitalize(resourcesToBeSynced[i].category), - resourcesToBeSynced[i].resourceName, - operation /*syncOperationLabel*/, - resourcesToBeSynced[i].providerPlugin, - ]); - } - for (let i = 0; i < resourcesToBeDeleted.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeDeleted[i].category), - resourcesToBeDeleted[i].resourceName, - deleteOperationLabel, - resourcesToBeDeleted[i].providerPlugin, - ]); - } +async function viewResourceDiffs( { resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated + } ){ + const resourceDiffs = await getResourceDiffs ( resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated ); + for await ( let resourceDiff of resourceDiffs.updatedDiff){ + //Print with UPDATE styling theme + resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.UPDATE); + } + print.info("\n"); + for await (let resourceDiff of resourceDiffs.deletedDiff){ + //Print with DELETE styling theme + resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.DELETE); + } + print.info("\n"); + for await (let resourceDiff of resourceDiffs.createdDiff){ + //Print with CREATE styling theme + resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.CREATE); + } + print.info("\n"); +} - for (let i = 0; i < noChangeResources.length; ++i) { - tableOptions.push([ - capitalize(noChangeResources[i].category), - noChangeResources[i].resourceName, - noOperationLabel, - noChangeResources[i].providerPlugin, - ]); - } +function viewEnvInfo(){ + const { envName } = getEnvInfo(); + print.info(` + ${chalk.green('Current Environment')}: ${envName} + `); +} +function viewSummaryTable( resourceStateData ){ + const tableOptions = getSummaryTableData( resourceStateData ) const { table } = print; + table(tableOptions, { format: 'lean' }); +} + +//Helper function to merge multi-category status +function mergeMultiCategoryStatus( accumulator : ICategoryStatusCollection, currentObject : ICategoryStatusCollection ){ + if ( !accumulator ){ + return currentObject; + } else if (!currentObject ){ + return accumulator; + } else { + let mergedResult :ICategoryStatusCollection = { + resourcesToBeCreated: accumulator.resourcesToBeCreated.concat(currentObject.resourcesToBeCreated), + resourcesToBeUpdated: accumulator.resourcesToBeUpdated.concat(currentObject.resourcesToBeUpdated), + resourcesToBeDeleted: accumulator.resourcesToBeDeleted.concat(currentObject.resourcesToBeDeleted), + resourcesToBeSynced: accumulator.resourcesToBeSynced.concat(currentObject.resourcesToBeSynced), + allResources:accumulator.allResources.concat(currentObject.allResources), + tagsUpdated: accumulator.tagsUpdated || currentObject.tagsUpdated + } + return mergedResult; + } +} + + + +//Filter resource status for the given categories +export async function getMultiCategoryStatus( inputs: ViewResourceTableParams | undefined ){ + if ( ! (inputs?.categoryList?.length) ){ + // all diffs ( amplify -v ) + return await getResourceStatus(); + } else { + let results : any[] = []; + //diffs for only the required categories (amplify -v ...) + for await ( let category of inputs.categoryList ){ + if( category ){ + const categoryResult = await getResourceStatus( category, + undefined, + undefined, + undefined ); + results.push(categoryResult); + } + } + + let mergedResult : ICategoryStatusCollection= { + resourcesToBeCreated: [], + resourcesToBeUpdated: [], + resourcesToBeDeleted: [], + resourcesToBeSynced: [], + allResources:[], + tagsUpdated: false + }; + results.map( resourceResult => { + mergedResult = mergeMultiCategoryStatus(mergedResult, resourceResult); + }); + + return mergedResult; + } +} + +export async function showStatusTable( tableViewFilter : ViewResourceTableParams ){ + const amplifyProjectInitStatus = getCloudInitStatus(); + const { + resourcesToBeCreated, + resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeSynced, + allResources, + tagsUpdated, + } = await getMultiCategoryStatus(tableViewFilter); + + //1. Display Environment Info + if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { + viewEnvInfo(); + } + //2. Display Summary Table + viewSummaryTable({ resourcesToBeUpdated, + resourcesToBeCreated, + resourcesToBeDeleted, + resourcesToBeSynced, + allResources + }); + //3. Display Tags Status + if (tagsUpdated) { + print.info('\nTag Changes Detected'); + } + + //4. Display Detailed Diffs (Cfn/NonCfn) + if ( tableViewFilter.verbose ) { + await viewResourceDiffs( { resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated } ); + } + + const resourceChanged = resourcesToBeCreated.length + + resourcesToBeUpdated.length + + resourcesToBeSynced.length + + resourcesToBeDeleted.length > 0 || tagsUpdated; + + return resourceChanged; +} + - table(tableOptions, { format: 'markdown' }); +export async function showResourceTable(category, resourceName, filteredResources) { + + //Prepare state for view + const { + resourcesToBeCreated, + resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeSynced, + allResources, + tagsUpdated, + } = await getResourceStatus(category, resourceName, undefined, filteredResources); + const amplifyProjectInitStatus = getCloudInitStatus(); + + //1. Display Environment Info + if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { + viewEnvInfo(); + } + //2. Display Summary Table + viewSummaryTable({ resourcesToBeUpdated, + resourcesToBeCreated, + resourcesToBeDeleted, + resourcesToBeSynced, + allResources + }); + //3. Display Tags Status if (tagsUpdated) { print.info('\nTag Changes Detected'); } + //4. Display Detailed Diffs (Cfn/NonCfn) + await viewResourceDiffs( { resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated } ); + const resourceChanged = resourcesToBeCreated.length + resourcesToBeUpdated.length + resourcesToBeSynced.length + resourcesToBeDeleted.length > 0 || tagsUpdated; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts b/packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts new file mode 100644 index 00000000000..e46c8f8d6bf --- /dev/null +++ b/packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts @@ -0,0 +1,59 @@ +import * as yaml from 'yaml'; +import * as yaml_cst from 'yaml/parse-cst'; +import * as yaml_types from 'yaml/types'; + +/** + * Serializes the given data structure into valid YAML. + * + * @param obj the data structure to serialize + * @returns a string containing the YAML representation of {@param obj} + */ +export function serialize(obj: any): string { + const oldFold = yaml_types.strOptions.fold.lineWidth; + try { + yaml_types.strOptions.fold.lineWidth = 0; + return yaml.stringify(obj, { schema: 'yaml-1.1' }); + } finally { + yaml_types.strOptions.fold.lineWidth = oldFold; + } +} + +/** + * Deserialize the YAML into the appropriate data structure. + * + * @param str the string containing YAML + * @returns the data structure the YAML represents + * (most often in case of CloudFormation, an object) + */ +export function deserialize(str: string): any { + return parseYamlStrWithCfnTags(str); +} + +function makeTagForCfnIntrinsic(intrinsicName: string, addFnPrefix: boolean): yaml_types.Schema.CustomTag { + return { + identify(value: any) { return typeof value === 'string'; }, + tag: `!${intrinsicName}`, + resolve: (_doc: yaml.Document, cstNode: yaml_cst.CST.Node) => { + const ret: any = {}; + ret[addFnPrefix ? `Fn::${intrinsicName}` : intrinsicName] = + // the +1 is to account for the ! the short form begins with + parseYamlStrWithCfnTags(cstNode.toString().substring(intrinsicName.length + 1)); + return ret; + }, + }; +} + +const shortForms: yaml_types.Schema.CustomTag[] = [ + 'Base64', 'Cidr', 'FindInMap', 'GetAZs', 'ImportValue', 'Join', 'Sub', + 'Select', 'Split', 'Transform', 'And', 'Equals', 'If', 'Not', 'Or', 'GetAtt', +].map(name => makeTagForCfnIntrinsic(name, true)).concat( + makeTagForCfnIntrinsic('Ref', false), + makeTagForCfnIntrinsic('Condition', false), +); + +function parseYamlStrWithCfnTags(text: string): any { + return yaml.parse(text, { + customTags: shortForms, + schema: 'core', + }); +} diff --git a/packages/amplify-cli/src/index.ts b/packages/amplify-cli/src/index.ts index 3f13b432cf9..af4a98dbb65 100644 --- a/packages/amplify-cli/src/index.ts +++ b/packages/amplify-cli/src/index.ts @@ -64,6 +64,19 @@ process.on('unhandledRejection', function (error) { throw error; }); +function normalizeStatusCommandOptions( statusCommandOptions){ + let options = statusCommandOptions; + //Normalize 'amplify status -v' to verbose, since -v is interpreted as 'version' + if ( options && options.hasOwnProperty('v') ){ + options.verbose = true; + if ( typeof options['v'] === 'string'){ + options[ options['v'] ] = true ; + } + delete options['v'] + } + return options; +} + // entry from commandline export async function run() { try { @@ -71,13 +84,17 @@ export async function run() { let pluginPlatform = await getPluginPlatform(); let input = getCommandLineInput(pluginPlatform); - // with non-help command supplied, give notification before execution if (input.command !== 'help') { // Checks for available update, defaults to a 1 day interval for notification notify({ defer: false, isGlobal: true }); } + //Normalize status command options + if ( input.command == 'status'){ + input.options = normalizeStatusCommandOptions(input.options) + } + // Initialize Banner messages. These messages are set on the server side const pkg = JSONUtilities.readJson<$TSAny>(path.join(__dirname, '..', 'package.json')); BannerMessage.initialize(pkg.version); From 8cc53889f2b40338987403132fe7dd7f30646ab1 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Wed, 7 Jul 2021 14:54:18 -0700 Subject: [PATCH 02/26] help and test --- packages/amplify-cli-core/src/cliViewAPI.ts | 41 +++++++++++- .../src/__tests__/commands/status.test.ts | 65 +++++++++++++++++++ packages/amplify-cli/src/commands/status.ts | 17 +++-- .../amplify-helpers/show-all-help.ts | 4 ++ 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 packages/amplify-cli/src/__tests__/commands/status.test.ts diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index a82142504d2..771d4e80842 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -1,14 +1,16 @@ //Use this file to store all types used between the CLI commands and the view/display functions // CLI=>(command-handler)==[CLI-View-API]=>(ux-handler/report-handler)=>output-stream - +import chalk from 'chalk'; export interface CLIParams { cliCommand : string; + cliSubcommands: string[]|undefined; cliOptions : {[key:string] : any}; } //Resource Table filter and display params (params used for summary/display view of resource table) export class ViewResourceTableParams { public command : string; public verbose : boolean; //display table in verbose mode + public help : boolean; //display help for the command public categoryList : string[]|[] //categories to display public filteredResourceList : any //resources to *not* display - TBD define union of valid types getCategoryFromCLIOptions( cliOptions : object ){ @@ -18,11 +20,48 @@ export class ViewResourceTableParams { return []; } } + styleHeader(str : string) { + return chalk.italic(chalk.bgGray.whiteBright(str)); + } + styleCommand( str : string) { + return chalk.greenBright(str); + } + styleOption( str : string) { + return chalk.yellowBright(str); + } + stylePrompt( str : string ){ + return chalk.bold(chalk.yellowBright(str)); + } + styleNOOP( str : string){ + return chalk.italic(chalk.grey(str)); + } + public getStyledHelp(){ + return ` +${this.styleHeader("NAME")} +${this.styleCommand("amplify status")} -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) + +${this.styleHeader("SYNOPSIS")} +${this.styleCommand("amplify status")} [${this.styleCommand("-v")} [${this.styleOption("category ...")}] ] + +${this.styleHeader("DESCRIPTION")} +The amplify status command displays the difference between the deployed state and the local state of the application. +The following options are available: + +${this.styleNOOP("no options")} : (Summary mode) Displays the summary of local state vs deployed state of the application +${this.styleCommand("-v [category ...]")} : (Verbose mode) Displays the cloudformation diff for all resources for the specificed category. + If no category is provided, it shows the diff for all categories. + usage: + ${this.stylePrompt("#\>")} ${this.styleCommand("amplify status -v")} + ${this.stylePrompt("#\>")} ${this.styleCommand("status -v ")}${this.styleOption( "api storage")} + + ` + } public constructor( cliParams : CLIParams ){ this.command = cliParams.cliCommand; this.verbose = (cliParams.cliOptions?.verbose === true ); this.categoryList = this.getCategoryFromCLIOptions( cliParams.cliOptions ); this.filteredResourceList = []; //TBD - add support to provide resources + this.help = (cliParams.cliSubcommands)?cliParams.cliSubcommands.includes("help"):false; } } \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/commands/status.test.ts b/packages/amplify-cli/src/__tests__/commands/status.test.ts new file mode 100644 index 00000000000..f9f6ddf190d --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/status.test.ts @@ -0,0 +1,65 @@ +import { UnknownArgumentError } from 'amplify-cli-core'; + + +describe('amplify status: ', () => { + const mockExit = jest.fn(); + jest.mock('amplify-cli-core', () => ({ + exitOnNextTick: mockExit, + UnknownArgumentError: UnknownArgumentError, + })); + const { run } = require('../../commands/status'); + const runStatusCmd = run; + + it('status run method should exist', () => { + expect(runStatusCmd).toBeDefined(); + }); + + it('status run method should call context.amplify.showStatusTable', async () => { + const mockContextNoCLArgs = { + amplify: { + showStatusTable: jest.fn(), + }, + parameters: { + array: [], + }, + }; + runStatusCmd(mockContextNoCLArgs) + expect(mockContextNoCLArgs.amplify.showStatusTable).toBeCalled(); + }); + + it('status -v run method should call context.amplify.showStatusTable', async () => { + const mockContextWithVerboseOptionAndCLArgs = { + amplify: { + showStatusTable: jest.fn(), + }, + input :{ + command: "status", + options: { + verbose : true + } + } + }; + runStatusCmd(mockContextWithVerboseOptionAndCLArgs) + expect(mockContextWithVerboseOptionAndCLArgs.amplify.showStatusTable).toBeCalled(); + }); + + it('status -v run method should call context.amplify.showStatusTable', async () => { + const mockContextWithVerboseOptionAndCLArgs = { + amplify: { + showStatusTable: jest.fn(), + }, + input :{ + command: "status", + options: { + verbose : true, + api : true, + storage : true + } + } + }; + runStatusCmd(mockContextWithVerboseOptionAndCLArgs) + expect(mockContextWithVerboseOptionAndCLArgs.amplify.showStatusTable).toBeCalled(); + }); + + +}) \ No newline at end of file diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index 7bf835b0fe8..91389901d41 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -1,12 +1,19 @@ +import { constructCloudWatchEventComponent } from "amplify-category-function/src/provider-utils/awscloudformation/utils/cloudformationHelpers"; import { ViewResourceTableParams, CLIParams } from "amplify-cli-core/lib/cliViewAPI"; export const run = async context => { - const cliParams:CLIParams = { cliCommand : context.input.command, - cliOptions : context.input.options } - await context.amplify.showStatusTable( new ViewResourceTableParams( cliParams ) ); - await context.amplify.showHelpfulProviderLinks(context); - await showAmplifyConsoleHostingStatus(context); + const cliParams:CLIParams = { cliCommand : context?.input?.command, + cliSubcommands: context?.input?.subCommands, + cliOptions : context?.input?.options } + const view = new ViewResourceTableParams( cliParams ); + if ( context?.input?.subCommands?.includes("help")){ + console.log( view.getStyledHelp()) + } else { + await context.amplify.showStatusTable( view ); + await context.amplify.showHelpfulProviderLinks(context); + await showAmplifyConsoleHostingStatus(context); + } }; async function showAmplifyConsoleHostingStatus( context) { diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts b/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts index b5a20a21974..fa45b85afe5 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts @@ -38,6 +38,10 @@ export function showAllHelp(context) { name: 'status', description: 'Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete).', }, + { + name: 'status -v [ ...]', + description: 'Shows the detailed verbose diff between local and deployed resources, including cloudformation-diff', + }, { name: 'delete', description: 'Deletes all of the resources tied to the project from the cloud.', From 00f0868b5ada769b90dbbbb2f9dc2ca92a76d999 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Wed, 7 Jul 2021 15:38:22 -0700 Subject: [PATCH 03/26] show summary view on amplify push --- .../src/extensions/amplify-helpers/resource-status.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index 7b9033361ca..f467d57e8b0 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -658,11 +658,7 @@ export async function showResourceTable(category, resourceName, filteredResource if (tagsUpdated) { print.info('\nTag Changes Detected'); } - - //4. Display Detailed Diffs (Cfn/NonCfn) - await viewResourceDiffs( { resourcesToBeUpdated, - resourcesToBeDeleted, - resourcesToBeCreated } ); + print.info(`\n${chalk.blueBright("Note: ")}Please use 'amplify status ${ chalk.greenBright("-v")}' to view detailed status and cloudformation-diff.\n`); const resourceChanged = resourcesToBeCreated.length + resourcesToBeUpdated.length + resourcesToBeSynced.length + resourcesToBeDeleted.length > 0 || tagsUpdated; From 4bc0ad64e734983c52a596a7e826f75be34f2454 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Wed, 7 Jul 2021 17:07:55 -0700 Subject: [PATCH 04/26] Fixed s3 --- .../amplify-helpers/resource-status-diff.ts | 21 +++++++++++-- .../amplify-helpers/resource-status.ts | 31 +++++++++++++------ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 0030278d7d9..cf54887bf54 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -7,7 +7,18 @@ import * as cxapi from '@aws-cdk/cx-api'; import { print } from './print'; import { pathManager } from 'amplify-cli-core'; import chalk from 'chalk'; - +import { getResourceService } from './resource-status'; + +const ResourceProviderServiceNames = { + S3 : "S3", + DDB : "DynamoDB", + LAMBDA : "Lambda", + S3AndCLOUDFNT: "S3AndCloudFront", + PINPOINT: "Pinpoint", + COGNITO: "Cognito", + APIGW: 'API Gateway', + APPSYNC : 'AppSync', +} const CategoryTypes = { PROVIDERS : "providers", API : "api", @@ -92,10 +103,12 @@ interface ResourcePaths { } + export class ResourceDiff { resourceName: string; category : string; provider : string; + service: string; resourceFiles : ResourcePaths; localBackendDir : string; cloudBackendDir : string; @@ -112,6 +125,7 @@ export class ResourceDiff { this.resourceName = resourceName; this.category = category; this.provider = this.normalizeProviderForFileNames(provider); + this.service = getResourceService(category, resourceName); this.localTemplate = {}; //requires file-access, hence loaded from async methods this.cloudTemplate = {}; //requires file-access, hence loaded from async methods //Note: All file names include full-path but no extension.Extension will be added later. @@ -125,7 +139,7 @@ export class ResourceDiff { localPreBuildTemplateFile: path.normalize(path.join(this.localBackendDir, category, resourceName, this.getResourceProviderFileName(resourceName, this.provider))), cloudPreBuildTemplateFile: path.normalize(path.join(this.cloudBackendDir , category, resourceName, this.getResourceProviderFileName(resourceName, this.provider))), } - //console.log("SACPC:ResourceDiff: ", this.resourceFiles); + console.log("SACPCDEBUG:ResourceDiff: ", this.resourceFiles); } normalizeProviderForFileNames(provider:string){ @@ -278,6 +292,9 @@ export class ResourceDiff { getResourceProviderFileName( resourceName : string, providerType : string ){ //resourceName is the name of an instantiated category type e.g name of your s3 bucket //providerType is the name of the cloud infrastructure provider e.g cloudformation/terraform + if( this.service === ResourceProviderServiceNames.S3 ){ + return `s3-cloudformation-template`; + } return `${resourceName}-${providerType}-template` } diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index f467d57e8b0..67ca869ddbc 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -11,6 +11,7 @@ import { removeGetUserEndpoints } from '../amplify-helpers/remove-pinpoint-polic import { pathManager, stateManager, $TSMeta, $TSAny, NotInitializedError, ViewResourceTableParams } from 'amplify-cli-core'; import * as resourceStatus from './resource-status-diff'; import { ResourceDiff, IResourceDiffCollection, ICategoryStatusCollection } from './resource-status-diff'; +import { getAmplifyMetaFilePath } from './path-manager'; async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPushTimeStamp, hashFunction) { @@ -354,22 +355,34 @@ async function asyncForEach(array, callback) { await callback(array[index], index, array); } } - -export async function getResourceStatus(category?, resourceName?, providerName?, filteredResources?) { - +export function getAmplifyMeta(){ const amplifyProjectInitStatus = getCloudInitStatus(); - let amplifyMeta: $TSAny; - let currentAmplifyMeta: $TSMeta = {}; - if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { - amplifyMeta = stateManager.getMeta(); - currentAmplifyMeta = stateManager.getCurrentMeta(); + return { + amplifyMeta : stateManager.getMeta(), + currentAmplifyMeta : stateManager.getCurrentMeta() + } } else if (amplifyProjectInitStatus === CLOUD_NOT_INITIALIZED) { - amplifyMeta = stateManager.getBackendConfig(); + return { + amplifyMeta : stateManager.getBackendConfig(), + currentAmplifyMeta : {} + } } else { throw new NotInitializedError(); } +} + +//Get the name of the AWS service provisioning the resource +export function getResourceService( category: string, resourceName: string){ + let { amplifyMeta } = getAmplifyMeta(); + const categoryMeta = (amplifyMeta)?amplifyMeta[category]:{}; + return categoryMeta[resourceName]?.service; +} +export async function getResourceStatus(category?, resourceName?, providerName?, filteredResources?) { + + const amplifyProjectInitStatus = getCloudInitStatus(); + let { amplifyMeta, currentAmplifyMeta } = getAmplifyMeta(); let resourcesToBeCreated: any = getResourcesToBeCreated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); let resourcesToBeUpdated: any = await getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); let resourcesToBeSynced: any = getResourcesToBeSynced(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); From 13997a39737bffb2becf1c1b4af2217dcbfe415b Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 9 Jul 2021 09:54:53 -0700 Subject: [PATCH 05/26] unit test for showStatusTable --- .../amplify-cli/src/__tests__/commands/status.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/amplify-cli/src/__tests__/commands/status.test.ts b/packages/amplify-cli/src/__tests__/commands/status.test.ts index f9f6ddf190d..9b7f057ca50 100644 --- a/packages/amplify-cli/src/__tests__/commands/status.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/status.test.ts @@ -43,8 +43,8 @@ describe('amplify status: ', () => { expect(mockContextWithVerboseOptionAndCLArgs.amplify.showStatusTable).toBeCalled(); }); - it('status -v run method should call context.amplify.showStatusTable', async () => { - const mockContextWithVerboseOptionAndCLArgs = { + it('status -v * run method should call context.amplify.showStatusTable', async () => { + const mockContextWithVerboseOptionWithCategoriesAndCLArgs = { amplify: { showStatusTable: jest.fn(), }, @@ -57,8 +57,8 @@ describe('amplify status: ', () => { } } }; - runStatusCmd(mockContextWithVerboseOptionAndCLArgs) - expect(mockContextWithVerboseOptionAndCLArgs.amplify.showStatusTable).toBeCalled(); + runStatusCmd(mockContextWithVerboseOptionWithCategoriesAndCLArgs) + expect(mockContextWithVerboseOptionWithCategoriesAndCLArgs.amplify.showStatusTable).toBeCalled(); }); From f47b3f39c95b3aaf79e9d5dd62e24972b201b1f9 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 9 Jul 2021 09:56:24 -0700 Subject: [PATCH 06/26] fixed ViewResourceTableParams --- .../src/extensions/amplify-helpers/resource-status.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index 67ca869ddbc..45005637f31 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -8,7 +8,8 @@ import { getEnvInfo } from './get-env-info'; import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; import { ServiceName as FunctionServiceName, hashLayerResource } from 'amplify-category-function'; import { removeGetUserEndpoints } from '../amplify-helpers/remove-pinpoint-policy'; -import { pathManager, stateManager, $TSMeta, $TSAny, NotInitializedError, ViewResourceTableParams } from 'amplify-cli-core'; +import { pathManager, stateManager, $TSMeta, $TSAny, NotInitializedError } from 'amplify-cli-core'; +import { ViewResourceTableParams } from "amplify-cli-core/lib/cliViewAPI"; import * as resourceStatus from './resource-status-diff'; import { ResourceDiff, IResourceDiffCollection, ICategoryStatusCollection } from './resource-status-diff'; import { getAmplifyMetaFilePath } from './path-manager'; From 1698a1489f22991600e72c120dc020b53444bc19 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 9 Jul 2021 14:00:21 -0700 Subject: [PATCH 07/26] Converted cloudformation file-path to use Glob --- packages/amplify-cli-core/src/cliViewAPI.ts | 2 +- .../amplify-helpers/resource-status-diff.ts | 184 +++++++----------- .../amplify-helpers/resource-status.ts | 69 +++---- 3 files changed, 103 insertions(+), 152 deletions(-) diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 771d4e80842..33d3a210b78 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -15,7 +15,7 @@ export class ViewResourceTableParams { public filteredResourceList : any //resources to *not* display - TBD define union of valid types getCategoryFromCLIOptions( cliOptions : object ){ if ( cliOptions ){ - return Object.keys(cliOptions).filter( key => (key != 'verbose') && (key !== 'yes') ) + return (Object.keys(cliOptions).filter( key => (key != 'verbose') && (key !== 'yes') )).map(category => category.toLowerCase()); } else { return []; } diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index cf54887bf54..7726429a03f 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -8,6 +8,8 @@ import { print } from './print'; import { pathManager } from 'amplify-cli-core'; import chalk from 'chalk'; import { getResourceService } from './resource-status'; +import { getBackendConfigFilePath } from './path-manager'; +import * as glob from 'glob'; const ResourceProviderServiceNames = { S3 : "S3", @@ -94,13 +96,12 @@ function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); } interface ResourcePaths { - cloudBuildTemplateFile : string; //cloud file path after transformation - localBuildTemplateFile : string; //file path cloudTemplateFile : string; localTemplateFile : string; - localPreBuildTemplateFile : string; - cloudPreBuildTemplateFile : string; - + localPreBuildCfnFile : string; + cloudPreBuildCfnFile : string; + localBuildCfnFile : string; + cloudBuildCfnFile : string; } @@ -132,14 +133,12 @@ export class ResourceDiff { this.resourceFiles = { localTemplateFile : path.normalize(path.join(this.localBackendDir, category, resourceName)), cloudTemplateFile : path.normalize(path.join(this.cloudBackendDir, category, resourceName)), - //Used for API category, since cloudformation is overridden by user generated changes - localBuildTemplateFile : path.normalize(path.join(this.localBackendDir, category, resourceName, 'build',`${this.provider}-template`)), - cloudBuildTemplateFile : path.normalize(path.join(this.cloudBackendDir, category, resourceName, 'build', `${this.provider}-template`)), - //Use for non-API category like storage, auth - localPreBuildTemplateFile: path.normalize(path.join(this.localBackendDir, category, resourceName, this.getResourceProviderFileName(resourceName, this.provider))), - cloudPreBuildTemplateFile: path.normalize(path.join(this.cloudBackendDir , category, resourceName, this.getResourceProviderFileName(resourceName, this.provider))), - } - console.log("SACPCDEBUG:ResourceDiff: ", this.resourceFiles); + //Paths using glob for Cfn file and Build file + localPreBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.localBackendDir, category, resourceName))), + cloudPreBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.cloudBackendDir, category, resourceName))), + localBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.localBackendDir, category, resourceName, 'build'))), + cloudBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.cloudBackendDir, category, resourceName, 'build'))), + } } normalizeProviderForFileNames(provider:string){ @@ -151,11 +150,11 @@ export class ResourceDiff { } } - calculateDiffTemplate = async () => { - const resourceTemplatePaths = await this.getResourceFilePaths() + calculateCfnDiff = async () =>{ + const resourceTemplatePaths = await this.getCfnResourceFilePaths(); //load resource template objects - this.localTemplate = await this.loadCloudFormationTemplate(resourceTemplatePaths.localTemplatePath) - this.cloudTemplate = await this.loadCloudFormationTemplate(resourceTemplatePaths.cloudTemplatePath); + this.localTemplate = await this.loadCfnTemplate(resourceTemplatePaths.localTemplatePath) + this.cloudTemplate = await this.loadCfnTemplate(resourceTemplatePaths.cloudTemplatePath); const diff = cfnDiff.diffTemplate(this.cloudTemplate, this.localTemplate ); return diff; } @@ -164,28 +163,17 @@ export class ResourceDiff { return this.category.toLowerCase() == categoryType } - getResourceFilePaths = async() => { - if ( this.isCategory(CategoryTypes.API) ){ - //API artifacts are stored in build folder for cloud resources - //SACPCTBD!!: This should not rely on the presence or absence of a file, it should be based on the context state. - //e.g user-added, amplify-built-not-deployed, amplify-deployed-failed, amplify-deployed-success - return { - localTemplatePath : (checkExist(this.resourceFiles.localBuildTemplateFile))?this.resourceFiles.localBuildTemplateFile: this.resourceFiles.localPreBuildTemplateFile , - cloudTemplatePath : (checkExist(this.resourceFiles.cloudBuildTemplateFile))?this.resourceFiles.cloudBuildTemplateFile: this.resourceFiles.cloudPreBuildTemplateFile - } - } - return { - localTemplatePath: this.resourceFiles.localPreBuildTemplateFile , - cloudTemplatePath: this.resourceFiles.cloudPreBuildTemplateFile - } + getCfnResourceFilePaths = async() => { + return { + localTemplatePath : (checkExist(this.resourceFiles.localBuildCfnFile))?this.resourceFiles.localBuildCfnFile: this.resourceFiles.localPreBuildCfnFile , + cloudTemplatePath : (checkExist(this.resourceFiles.cloudBuildCfnFile))?this.resourceFiles.cloudBuildCfnFile: this.resourceFiles.cloudPreBuildCfnFile + } } printResourceDetailStatus = async ( mutationInfo : StackMutationInfo ) => { const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; - const diff = await this.calculateDiffTemplate(); - //display template diff to console - //print.info(`[\u27A5] ${vaporStyle("Stack: ")} ${capitalize(this.category)}/${this.resourceName} : ${header}`); - print.info(`${vaporStyle(`[\u27A5] Resource Stack: ${capitalize(this.category)}/${this.resourceName}`)} : ${header}`); + const diff = await this.calculateCfnDiff(); + print.info(`${resourceDetailSectionStyle(`[\u27A5] Resource Stack: ${capitalize(this.category)}/${this.resourceName}`)} : ${header}`); const diffCount = this.printStackDiff( diff ); if ( diffCount === 0 ){ console.log("No changes ") @@ -198,80 +186,6 @@ export class ResourceDiff { return parts.join("::") } - normalizeCdkChangeImpact( cdkChangeImpact : cfnDiff.ResourceImpact ){ - switch (cdkChangeImpact) { - case cfnDiff.ResourceImpact.MAY_REPLACE: - return chalk.italic(chalk.yellow('may be replaced')); - case cfnDiff.ResourceImpact.WILL_REPLACE: - return chalk.italic(chalk.bold(chalk.red('replace'))); - case cfnDiff.ResourceImpact.WILL_DESTROY: - return chalk.italic(chalk.bold(chalk.red('destroy'))); - case cfnDiff.ResourceImpact.WILL_ORPHAN: - return chalk.italic(chalk.yellow('orphan')); - case cfnDiff.ResourceImpact.WILL_UPDATE: - case cfnDiff.ResourceImpact.WILL_CREATE: - case cfnDiff.ResourceImpact.NO_CHANGE: - return ''; // no extra info is gained here - } - } - - getActionStyle( changeType:StackMutationInfo, valueType ){ - return ` ${changeType.consoleStyle(changeType.icon)} ${this.normalizeAWSResourceName(valueType)}` - } - - getUpdateImpactStyle( summaryStringArray ){ - return summaryStringArray.join(`\n \u2514\u2501 `) - } - - constructChangeSummary( change ){ - let action : string; - let propChangeSummary : string[] = []; - if ( change.isAddition ){ - action = this.getActionStyle(stackMutationType.CREATE, change.newValue.Type); - } else if (change.isRemoval){ - action = this.getActionStyle(stackMutationType.DELETE, change.oldValue.Type); - } else { - //Its an update - action = this.getActionStyle(stackMutationType.UPDATE, change.newValue.Type); - Object.keys(change.propertyDiffs).map( propType => { - const changeData = change.propertyDiffs[propType]; - if ( changeData.isDifferent ) { - const summary = `${propType} ${this.normalizeCdkChangeImpact(changeData.changeImpact)}`; - propChangeSummary.push(summary) - } - }) - } - if ( propChangeSummary.length > 0 ){ - const summaryString = this.getUpdateImpactStyle(propChangeSummary) - return `${action} ${summaryString}` - } else { - return action - } - } - - getDiffSummary = async () => { - const templateDiff = await this.calculateDiffTemplate(); - const results : string[] = []; - templateDiff.resources.forEachDifference( (logicalId, change)=> { - results.push( this.constructChangeSummary( change )); - }); - if ( results.length > 0){ - return results.join("\n"); - } else { - return chalk.grey("no change"); - } - } - - printResourceSummaryStatus = async ( mutationInfo : StackMutationInfo ) => { - const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; - const templateDiff = await this.calculateDiffTemplate(); - //display template diff to console - print.info(`[\u27A5] ${vaporStyle("Stack: ")} ${capitalize(this.category)}/${this.resourceName} : ${header}`); - templateDiff.resources.forEachDifference( (logicalId, change)=> { - console.log(this.constructChangeSummary( change ) ); - }) - } - printStackDiff = ( templateDiff, stream?: cfnDiff.FormatStream ) =>{ // filter out 'AWS::CDK::Metadata' resources from the template if (templateDiff.resources ) { @@ -324,6 +238,52 @@ export class ResourceDiff { } } + getFilePathExtension( filePath :string ){ + const ext = path.extname( filePath ); + return (ext)? (ext.substring(1)).toLowerCase() : "" ; + } + + globCfnFilePath( fileFolder : string ){ + if (fs.existsSync(fileFolder )) { + const globOptions: glob.IOptions = { + absolute: false, + cwd: fileFolder, + follow: false, + nodir: true, + }; + + const templateFileNames = glob.sync('**/*template.{yaml,yml,json}', globOptions); + for (const templateFileName of templateFileNames) { + const absolutePath = path.join(fileFolder, templateFileName); + return absolutePath; //We support only one cloudformation template ( nested templates are picked ) + } + } + return "" + } + + //Load Cloudformation template from file. + //The filePath should end in json/yaml or yml + loadCfnTemplate = async(filePath) => { + if( filePath === ""){ + return {} + } + const fileType = this.getFilePathExtension(filePath) + try { + //Load yaml or yml or json files + let providerObject = {}; + if (fs.existsSync(filePath) ){ + providerObject = await this.loadStructuredFile(filePath, //Filename with extension + InputFileExtensionDeserializers[fileType] //Deserialization function + ); + } + return providerObject; + } catch (e) { + //No resource file found + console.log(e); + throw e; + } + } + } function checkExist( filePath ){ @@ -350,11 +310,11 @@ export interface ICategoryStatusCollection { resourcesToBeDeleted: any[], resourcesToBeSynced: any[], allResources:any[], - tagsUpdated: false + tagsUpdated: boolean } -//const vaporStyle = chalk.hex('#8be8fd').bgHex('#282a36'); -const vaporStyle = chalk.bgRgb(15, 100, 204) +//Console text styling for resource details section +const resourceDetailSectionStyle = chalk.bgRgb(15, 100, 204) export async function CollateResourceDiffs( resources , mutationInfo : StackMutationInfo /* create/update/delete */ ){ diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index 45005637f31..6db5d59ab0c 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -380,7 +380,7 @@ export function getResourceService( category: string, resourceName: string){ return categoryMeta[resourceName]?.service; } -export async function getResourceStatus(category?, resourceName?, providerName?, filteredResources?) { +export async function getResourceStatus(category?, resourceName?, providerName?, filteredResources?): Promise{ const amplifyProjectInitStatus = getCloudInitStatus(); let { amplifyMeta, currentAmplifyMeta } = getAmplifyMeta(); @@ -402,14 +402,14 @@ export async function getResourceStatus(category?, resourceName?, providerName?, // if not equal there is a tag update const tagsUpdated = !_.isEqual(stateManager.getProjectTags(), stateManager.getCurrentProjectTags()); - return { - resourcesToBeCreated, - resourcesToBeUpdated, - resourcesToBeSynced, - resourcesToBeDeleted, - tagsUpdated, - allResources, - }; + return { + resourcesToBeCreated, + resourcesToBeUpdated, + resourcesToBeSynced, + resourcesToBeDeleted, + tagsUpdated, + allResources, + }; } @@ -565,41 +565,32 @@ function mergeMultiCategoryStatus( accumulator : ICategoryStatusCollection, curr } - +function resourceBelongsToCategoryList( category , categoryList ){ + if( typeof category === 'string'){ + return categoryList.includes( category ) + } else { + return false; + } +} +function filterResourceCategory( resourceList , categoryList){ + return (resourceList)? resourceList.filter(resource => resourceBelongsToCategoryList(resource.category, categoryList)) : [] +} //Filter resource status for the given categories export async function getMultiCategoryStatus( inputs: ViewResourceTableParams | undefined ){ - if ( ! (inputs?.categoryList?.length) ){ - // all diffs ( amplify -v ) - return await getResourceStatus(); - } else { - let results : any[] = []; + let resourceStatusResults = await getResourceStatus(); + if ( inputs?.categoryList?.length ){ //diffs for only the required categories (amplify -v ...) - for await ( let category of inputs.categoryList ){ - if( category ){ - const categoryResult = await getResourceStatus( category, - undefined, - undefined, - undefined ); - results.push(categoryResult); - } - } - - let mergedResult : ICategoryStatusCollection= { - resourcesToBeCreated: [], - resourcesToBeUpdated: [], - resourcesToBeDeleted: [], - resourcesToBeSynced: [], - allResources:[], - tagsUpdated: false - }; - results.map( resourceResult => { - mergedResult = mergeMultiCategoryStatus(mergedResult, resourceResult); - }); - - return mergedResult; - } + //TBD: optimize search + resourceStatusResults.resourcesToBeCreated = filterResourceCategory(resourceStatusResults.resourcesToBeCreated, inputs.categoryList); + resourceStatusResults.resourcesToBeUpdated = filterResourceCategory(resourceStatusResults.resourcesToBeUpdated, inputs.categoryList); + resourceStatusResults.resourcesToBeSynced = filterResourceCategory(resourceStatusResults.resourcesToBeSynced, inputs.categoryList); + resourceStatusResults.resourcesToBeDeleted = filterResourceCategory(resourceStatusResults.resourcesToBeDeleted, inputs.categoryList); + resourceStatusResults.allResources = filterResourceCategory(resourceStatusResults.allResources, inputs.categoryList) + } + return resourceStatusResults; } + export async function showStatusTable( tableViewFilter : ViewResourceTableParams ){ const amplifyProjectInitStatus = getCloudInitStatus(); const { From a860901a4356d1a5350352c2046df1bfc9e2c7ab Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 9 Jul 2021 14:38:46 -0700 Subject: [PATCH 08/26] cleanup and headers --- .../amplify-helpers/resource-status-diff.ts | 94 ++++++------------- 1 file changed, 30 insertions(+), 64 deletions(-) diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 7726429a03f..03682584704 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -141,52 +141,47 @@ export class ResourceDiff { } } - normalizeProviderForFileNames(provider:string){ - //file-names are currently standardized around "cloudformation" not awscloudformation. - if ( provider === "awscloudformation"){ - return CategoryProviders.CLOUDFORMATION - } else { - return provider; + //API :View: Print the resource detail status for the given mutation (Create/Update/Delete) + public printResourceDetailStatus = async ( mutationInfo : StackMutationInfo ) => { + const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; + const diff = await this.calculateCfnDiff(); + print.info(`${resourceDetailSectionStyle(`[\u27A5] Resource Stack: ${capitalize(this.category)}/${this.resourceName}`)} : ${header}`); + const diffCount = this.printStackDiff( diff ); + if ( diffCount === 0 ){ + console.log("No changes ") } } - calculateCfnDiff = async () =>{ + //API :Data: Calculate the difference in cloudformation templates between local and cloud templates + public calculateCfnDiff = async () => { const resourceTemplatePaths = await this.getCfnResourceFilePaths(); //load resource template objects this.localTemplate = await this.loadCfnTemplate(resourceTemplatePaths.localTemplatePath) this.cloudTemplate = await this.loadCfnTemplate(resourceTemplatePaths.cloudTemplatePath); - const diff = cfnDiff.diffTemplate(this.cloudTemplate, this.localTemplate ); + const diff : cfnDiff.TemplateDiff = cfnDiff.diffTemplate(this.cloudTemplate, this.localTemplate ); return diff; } - isCategory( categoryType ){ - return this.category.toLowerCase() == categoryType - } - - getCfnResourceFilePaths = async() => { + //helper: Select cloudformation file path from build folder or non build folder. + private getCfnResourceFilePaths = async() => { return { localTemplatePath : (checkExist(this.resourceFiles.localBuildCfnFile))?this.resourceFiles.localBuildCfnFile: this.resourceFiles.localPreBuildCfnFile , cloudTemplatePath : (checkExist(this.resourceFiles.cloudBuildCfnFile))?this.resourceFiles.cloudBuildCfnFile: this.resourceFiles.cloudPreBuildCfnFile } } - printResourceDetailStatus = async ( mutationInfo : StackMutationInfo ) => { - const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; - const diff = await this.calculateCfnDiff(); - print.info(`${resourceDetailSectionStyle(`[\u27A5] Resource Stack: ${capitalize(this.category)}/${this.resourceName}`)} : ${header}`); - const diffCount = this.printStackDiff( diff ); - if ( diffCount === 0 ){ - console.log("No changes ") + //helper: Get category provider from filename + private normalizeProviderForFileNames(provider:string):string{ + //file-names are currently standardized around "cloudformation" not awscloudformation. + if ( provider === "awscloudformation"){ + return CategoryProviders.CLOUDFORMATION + } else { + return provider; } } - normalizeAWSResourceName( cfnType ){ - let parts = cfnType.split("::"); - parts.shift(); //remove "AWS::" - return parts.join("::") - } - - printStackDiff = ( templateDiff, stream?: cfnDiff.FormatStream ) =>{ + //helper: Convert cloudformation template diff data using CDK api + private printStackDiff = ( templateDiff, stream?: cfnDiff.FormatStream ) =>{ // filter out 'AWS::CDK::Metadata' resources from the template if (templateDiff.resources ) { templateDiff.resources = templateDiff.resources.filter(change => { @@ -202,56 +197,27 @@ export class ResourceDiff { return templateDiff.differenceCount; } - - getResourceProviderFileName( resourceName : string, providerType : string ){ - //resourceName is the name of an instantiated category type e.g name of your s3 bucket - //providerType is the name of the cloud infrastructure provider e.g cloudformation/terraform - if( this.service === ResourceProviderServiceNames.S3 ){ - return `s3-cloudformation-template`; - } - return `${resourceName}-${providerType}-template` - } - - loadStructuredFile = async(fileName: string, deserializeFunction) => { + //helper: reads file and deserializes + private loadStructuredFile = async(fileName: string, deserializeFunction) => { const contents = await fs.readFile(fileName, { encoding: 'utf-8' }); return deserializeFunction(contents); } - loadCloudFormationTemplate = async( filePath ) => { - try { - //Load yaml or yml or json files - let providerObject = {}; - const inputFileExtensions = Object.keys(InputFileExtensionDeserializers) - for( let i = 0 ; i < inputFileExtensions.length ; i++ ){ - if ( fs.existsSync(`${filePath}.${inputFileExtensions[i]}`) ){ - providerObject = await this.loadStructuredFile(`${filePath}.${inputFileExtensions[i]}`, //Filename with extension - InputFileExtensionDeserializers[inputFileExtensions[i]] //Deserialization function - ); - return providerObject; - } - } - return providerObject; - } catch (e) { - //No resource file found - console.log(e); - throw e; - } - } - - getFilePathExtension( filePath :string ){ + //helper: returns file-extention of the filePath + private getFilePathExtension( filePath :string ){ const ext = path.extname( filePath ); return (ext)? (ext.substring(1)).toLowerCase() : "" ; } - globCfnFilePath( fileFolder : string ){ - if (fs.existsSync(fileFolder )) { + //Search and return the path to cloudformation template in the given folder + private globCfnFilePath( fileFolder : string ){ + if (fs.existsSync( fileFolder )) { const globOptions: glob.IOptions = { absolute: false, cwd: fileFolder, follow: false, nodir: true, }; - const templateFileNames = glob.sync('**/*template.{yaml,yml,json}', globOptions); for (const templateFileName of templateFileNames) { const absolutePath = path.join(fileFolder, templateFileName); @@ -263,7 +229,7 @@ export class ResourceDiff { //Load Cloudformation template from file. //The filePath should end in json/yaml or yml - loadCfnTemplate = async(filePath) => { + private loadCfnTemplate = async(filePath) => { if( filePath === ""){ return {} } From fd931bbdfd2d0f74594ce273924e6de1a929f007 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Sun, 11 Jul 2021 16:51:37 -0700 Subject: [PATCH 09/26] 1. removed custom yaml handling and used library code. 2. Used readCFNTemplate to load templates. 3. Cleaned up unused code --- .../amplify-helpers/resource-status-diff.ts | 173 +++++++----------- .../extensions/amplify-helpers/yaml-cfn.ts | 59 ------ 2 files changed, 67 insertions(+), 165 deletions(-) delete mode 100644 packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 03682584704..9e2ea4acbb9 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -2,33 +2,12 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import * as cfnDiff from '@aws-cdk/cloudformation-diff'; -import * as yaml_cfn from './yaml-cfn'; -import * as cxapi from '@aws-cdk/cx-api'; import { print } from './print'; -import { pathManager } from 'amplify-cli-core'; +import { pathManager, readCFNTemplate } from 'amplify-cli-core'; import chalk from 'chalk'; import { getResourceService } from './resource-status'; -import { getBackendConfigFilePath } from './path-manager'; import * as glob from 'glob'; -const ResourceProviderServiceNames = { - S3 : "S3", - DDB : "DynamoDB", - LAMBDA : "Lambda", - S3AndCLOUDFNT: "S3AndCloudFront", - PINPOINT: "Pinpoint", - COGNITO: "Cognito", - APIGW: 'API Gateway', - APPSYNC : 'AppSync', -} -const CategoryTypes = { - PROVIDERS : "providers", - API : "api", - AUTH : "auth", - STORAGE : "storage", - FUNCTION : "function", - ANALYTICS : "analytics" -} const CategoryProviders = { CLOUDFORMATION : "cloudformation", @@ -39,6 +18,7 @@ interface StackMutationInfo { consoleStyle : (string)=>string ; icon : String; } +//helper for summary styling interface StackMutationType { CREATE : StackMutationInfo, UPDATE : StackMutationInfo, @@ -47,6 +27,7 @@ interface StackMutationType { UNLINK : StackMutationInfo, NOCHANGE : StackMutationInfo, } +//helper map from mutation-type to ux styling export const stackMutationType : StackMutationType = { CREATE : { label : "Create", @@ -81,36 +62,47 @@ export const stackMutationType : StackMutationType = { consoleStyle : chalk.grey, icon : `[ ]` } - -} - -//TBD: move this to a class -//Maps File extension to deserializer functions -const InputFileExtensionDeserializers = { - json : JSON.parse, - yaml : yaml_cfn.deserialize, - yml : yaml_cfn.deserialize } +//Console text styling for resource details section +const resourceDetailSectionStyle = chalk.bgRgb(15, 100, 204) +//helper to capitalize string function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); } -interface ResourcePaths { - cloudTemplateFile : string; - localTemplateFile : string; + +//IResourcePaths: Interface for (build/prebuild) paths to local and cloud CFN files +interface IResourcePaths { localPreBuildCfnFile : string; cloudPreBuildCfnFile : string; localBuildCfnFile : string; cloudBuildCfnFile : string; } +export function globCFNFilePath( fileFolder : string ){ + if (fs.existsSync( fileFolder )) { + const globOptions: glob.IOptions = { + absolute: false, + cwd: fileFolder, + follow: false, + nodir: true, + }; + const templateFileNames = glob.sync('**/*template.{yaml,yml,json}', globOptions); + for (const templateFileName of templateFileNames) { + const absolutePath = path.join(fileFolder, templateFileName); + return absolutePath; //only the top level cloudformation ( nested templates are picked after parsing this file ) + } + } + throw new Error(`No CloudFormation template found in ${fileFolder}`); +} + export class ResourceDiff { resourceName: string; category : string; provider : string; service: string; - resourceFiles : ResourcePaths; + resourceFiles : IResourcePaths; localBackendDir : string; cloudBackendDir : string; localTemplate : { @@ -129,15 +121,16 @@ export class ResourceDiff { this.service = getResourceService(category, resourceName); this.localTemplate = {}; //requires file-access, hence loaded from async methods this.cloudTemplate = {}; //requires file-access, hence loaded from async methods - //Note: All file names include full-path but no extension.Extension will be added later. + //Resource Path state + const localResourceAbsolutePathFolder = path.normalize(path.join(this.localBackendDir, category, resourceName)); + const cloudResourceAbsolutePathFolder = path.normalize(path.join(this.cloudBackendDir, category, resourceName)); this.resourceFiles = { - localTemplateFile : path.normalize(path.join(this.localBackendDir, category, resourceName)), - cloudTemplateFile : path.normalize(path.join(this.cloudBackendDir, category, resourceName)), //Paths using glob for Cfn file and Build file - localPreBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.localBackendDir, category, resourceName))), - cloudPreBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.cloudBackendDir, category, resourceName))), - localBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.localBackendDir, category, resourceName, 'build'))), - cloudBuildCfnFile : this.globCfnFilePath( path.normalize(path.join(this.cloudBackendDir, category, resourceName, 'build'))), + localPreBuildCfnFile : this.safeGlobCFNFilePath(localResourceAbsolutePathFolder), + cloudPreBuildCfnFile : this.safeGlobCFNFilePath(cloudResourceAbsolutePathFolder), + //Build folder exists for services like GraphQL api which have an additional build step to generate CFN. + localBuildCfnFile : this.safeGlobCFNFilePath(path.normalize(path.join(localResourceAbsolutePathFolder, 'build'))), + cloudBuildCfnFile : this.safeGlobCFNFilePath(path.normalize(path.join(cloudResourceAbsolutePathFolder, 'build'))), } } @@ -146,28 +139,43 @@ export class ResourceDiff { const header = `${ mutationInfo.consoleStyle(mutationInfo.label)}`; const diff = await this.calculateCfnDiff(); print.info(`${resourceDetailSectionStyle(`[\u27A5] Resource Stack: ${capitalize(this.category)}/${this.resourceName}`)} : ${header}`); - const diffCount = this.printStackDiff( diff ); + const diffCount = this.printStackDiff( diff, process.stdout ); if ( diffCount === 0 ){ console.log("No changes ") } } //API :Data: Calculate the difference in cloudformation templates between local and cloud templates - public calculateCfnDiff = async () => { + public calculateCfnDiff = async () : Promise => { const resourceTemplatePaths = await this.getCfnResourceFilePaths(); - //load resource template objects - this.localTemplate = await this.loadCfnTemplate(resourceTemplatePaths.localTemplatePath) - this.cloudTemplate = await this.loadCfnTemplate(resourceTemplatePaths.cloudTemplatePath); + //set the member template objects + this.localTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.localTemplatePath); + this.cloudTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.cloudTemplatePath); + + //calculate diff of graphs. const diff : cfnDiff.TemplateDiff = cfnDiff.diffTemplate(this.cloudTemplate, this.localTemplate ); return diff; } + + //helper: wrapper around readCFNTemplate type to handle expressions. + safeReadCFNTemplate = async(filePath : string ) => { + try { + const templateResult = await readCFNTemplate(filePath); + return templateResult.cfnTemplate; + } catch (e){ + return {}; + } + } + //helper: Select cloudformation file path from build folder or non build folder. + //TBD: Update this function to infer filepath based on the state of the resource. private getCfnResourceFilePaths = async() => { - return { - localTemplatePath : (checkExist(this.resourceFiles.localBuildCfnFile))?this.resourceFiles.localBuildCfnFile: this.resourceFiles.localPreBuildCfnFile , - cloudTemplatePath : (checkExist(this.resourceFiles.cloudBuildCfnFile))?this.resourceFiles.cloudBuildCfnFile: this.resourceFiles.cloudPreBuildCfnFile + const resourceFilePaths = { + localTemplatePath : (checkExist(this.resourceFiles.localBuildCfnFile))?this.resourceFiles.localBuildCfnFile: this.resourceFiles.localPreBuildCfnFile , + cloudTemplatePath : (checkExist(this.resourceFiles.cloudBuildCfnFile))?this.resourceFiles.cloudBuildCfnFile: this.resourceFiles.cloudPreBuildCfnFile } + return resourceFilePaths; } //helper: Get category provider from filename @@ -182,7 +190,7 @@ export class ResourceDiff { //helper: Convert cloudformation template diff data using CDK api private printStackDiff = ( templateDiff, stream?: cfnDiff.FormatStream ) =>{ - // filter out 'AWS::CDK::Metadata' resources from the template + // filter out 'AWS::CDK::Metadata' since info is not helpful and formatDifferences doesnt know how to format it. if (templateDiff.resources ) { templateDiff.resources = templateDiff.resources.filter(change => { if (!change) { return true; } @@ -197,59 +205,14 @@ export class ResourceDiff { return templateDiff.differenceCount; } - //helper: reads file and deserializes - private loadStructuredFile = async(fileName: string, deserializeFunction) => { - const contents = await fs.readFile(fileName, { encoding: 'utf-8' }); - return deserializeFunction(contents); - } - - //helper: returns file-extention of the filePath - private getFilePathExtension( filePath :string ){ - const ext = path.extname( filePath ); - return (ext)? (ext.substring(1)).toLowerCase() : "" ; - } - //Search and return the path to cloudformation template in the given folder - private globCfnFilePath( fileFolder : string ){ - if (fs.existsSync( fileFolder )) { - const globOptions: glob.IOptions = { - absolute: false, - cwd: fileFolder, - follow: false, - nodir: true, - }; - const templateFileNames = glob.sync('**/*template.{yaml,yml,json}', globOptions); - for (const templateFileName of templateFileNames) { - const absolutePath = path.join(fileFolder, templateFileName); - return absolutePath; //We support only one cloudformation template ( nested templates are picked ) - } - } - return "" - } - - //Load Cloudformation template from file. - //The filePath should end in json/yaml or yml - private loadCfnTemplate = async(filePath) => { - if( filePath === ""){ - return {} - } - const fileType = this.getFilePathExtension(filePath) + private safeGlobCFNFilePath( fileFolder : string ){ try { - //Load yaml or yml or json files - let providerObject = {}; - if (fs.existsSync(filePath) ){ - providerObject = await this.loadStructuredFile(filePath, //Filename with extension - InputFileExtensionDeserializers[fileType] //Deserialization function - ); - } - return providerObject; - } catch (e) { - //No resource file found - console.log(e); - throw e; + return globCFNFilePath(fileFolder); + } catch ( e ){ + return ""; } } - } function checkExist( filePath ){ @@ -262,7 +225,7 @@ function checkExist( filePath ){ return false; } -//Interface to store template-diffs for C-R-U resource diffs +//Interface to store template-diffs for C-D-U resource diffs export interface IResourceDiffCollection { updatedDiff : ResourceDiff[]|[], deletedDiff : ResourceDiff[]|[], @@ -279,11 +242,9 @@ export interface ICategoryStatusCollection { tagsUpdated: boolean } -//Console text styling for resource details section -const resourceDetailSectionStyle = chalk.bgRgb(15, 100, 204) - - -export async function CollateResourceDiffs( resources , mutationInfo : StackMutationInfo /* create/update/delete */ ){ +//"CollateResourceDiffs" - Calculates the diffs for the list of resources provided. +// note:- The mutationInfo may be used for styling in enhanced summary. +export async function CollateResourceDiffs( resources , _mutationInfo : StackMutationInfo /* create/update/delete */ ){ const provider = CategoryProviders.CLOUDFORMATION; let resourceDiffs : ResourceDiff[] = []; for await (let resource of resources) { diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts b/packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts deleted file mode 100644 index e46c8f8d6bf..00000000000 --- a/packages/amplify-cli/src/extensions/amplify-helpers/yaml-cfn.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as yaml from 'yaml'; -import * as yaml_cst from 'yaml/parse-cst'; -import * as yaml_types from 'yaml/types'; - -/** - * Serializes the given data structure into valid YAML. - * - * @param obj the data structure to serialize - * @returns a string containing the YAML representation of {@param obj} - */ -export function serialize(obj: any): string { - const oldFold = yaml_types.strOptions.fold.lineWidth; - try { - yaml_types.strOptions.fold.lineWidth = 0; - return yaml.stringify(obj, { schema: 'yaml-1.1' }); - } finally { - yaml_types.strOptions.fold.lineWidth = oldFold; - } -} - -/** - * Deserialize the YAML into the appropriate data structure. - * - * @param str the string containing YAML - * @returns the data structure the YAML represents - * (most often in case of CloudFormation, an object) - */ -export function deserialize(str: string): any { - return parseYamlStrWithCfnTags(str); -} - -function makeTagForCfnIntrinsic(intrinsicName: string, addFnPrefix: boolean): yaml_types.Schema.CustomTag { - return { - identify(value: any) { return typeof value === 'string'; }, - tag: `!${intrinsicName}`, - resolve: (_doc: yaml.Document, cstNode: yaml_cst.CST.Node) => { - const ret: any = {}; - ret[addFnPrefix ? `Fn::${intrinsicName}` : intrinsicName] = - // the +1 is to account for the ! the short form begins with - parseYamlStrWithCfnTags(cstNode.toString().substring(intrinsicName.length + 1)); - return ret; - }, - }; -} - -const shortForms: yaml_types.Schema.CustomTag[] = [ - 'Base64', 'Cidr', 'FindInMap', 'GetAZs', 'ImportValue', 'Join', 'Sub', - 'Select', 'Split', 'Transform', 'And', 'Equals', 'If', 'Not', 'Or', 'GetAtt', -].map(name => makeTagForCfnIntrinsic(name, true)).concat( - makeTagForCfnIntrinsic('Ref', false), - makeTagForCfnIntrinsic('Condition', false), -); - -function parseYamlStrWithCfnTags(text: string): any { - return yaml.parse(text, { - customTags: shortForms, - schema: 'core', - }); -} From 848ba084befea7b5e4467d268c6a3780eef9d317 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Sun, 11 Jul 2021 23:35:03 -0700 Subject: [PATCH 10/26] addressed PR comments --- Readme.md | 1 + packages/amplify-cli-core/src/cliViewAPI.ts | 91 ++- .../amplify-helpers/resource-status-data.ts | 537 ++++++++++++++++ .../amplify-helpers/resource-status-diff.ts | 9 +- .../amplify-helpers/resource-status-view.ts | 39 ++ .../amplify-helpers/resource-status.ts | 598 +----------------- .../amplify-e2e-core/src/init/amplifyPush.ts | 11 + 7 files changed, 655 insertions(+), 631 deletions(-) create mode 100644 packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts create mode 100644 packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts diff --git a/Readme.md b/Readme.md index a0611ff40b9..d82754b85fc 100644 --- a/Readme.md +++ b/Readme.md @@ -65,6 +65,7 @@ The Amplify CLI supports the commands shown in the following table. | amplify pull | Fetch upstream backend environment definition changes from the cloud and updates the local environment to match that definition. | | amplify publish | Runs `amplify push`, publishes a static assets to Amazon S3 and Amazon CloudFront (\*hosting category is required). | | amplify status | Displays the state of local resources that haven't been pushed to the cloud (Create/Update/Delete). | +| amplify status -v [ ``...] | verbose mode - Shows the detailed verbose diff between local and deployed resources, including cloudformation-diff | | amplify serve | Runs `amplify push`, and then executes the project's start command to test run the client-side application. | | amplify delete | Deletes resources tied to the project. | | amplify help \| amplify `` help | Displays help for the core CLI. | diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 33d3a210b78..f8eb780947b 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -2,66 +2,87 @@ // CLI=>(command-handler)==[CLI-View-API]=>(ux-handler/report-handler)=>output-stream import chalk from 'chalk'; export interface CLIParams { - cliCommand : string; - cliSubcommands: string[]|undefined; - cliOptions : {[key:string] : any}; + cliCommand: string; + cliSubcommands: string[] | undefined; + cliOptions: { [key: string]: any }; } //Resource Table filter and display params (params used for summary/display view of resource table) export class ViewResourceTableParams { - public command : string; - public verbose : boolean; //display table in verbose mode - public help : boolean; //display help for the command - public categoryList : string[]|[] //categories to display - public filteredResourceList : any //resources to *not* display - TBD define union of valid types - getCategoryFromCLIOptions( cliOptions : object ){ - if ( cliOptions ){ - return (Object.keys(cliOptions).filter( key => (key != 'verbose') && (key !== 'yes') )).map(category => category.toLowerCase()); + private _command: string; + private _verbose: boolean; //display table in verbose mode + private _help: boolean; //display help for the command + private _categoryList: string[] | []; //categories to display + private _filteredResourceList: any; //resources to *not* display - TBD define union of valid types + + public get command() { + return this._command; + } + public get verbose() { + return this._verbose; + } + public get help() { + return this._help; + } + + public get categoryList() { + return this._categoryList; + } + + public get filteredResourceList() { + return this._filteredResourceList; + } + + getCategoryFromCLIOptions(cliOptions: object) { + if (cliOptions) { + return Object.keys(cliOptions) + .filter(key => key != 'verbose' && key !== 'yes') + .map(category => category.toLowerCase()); } else { return []; } } - styleHeader(str : string) { + styleHeader(str: string) { return chalk.italic(chalk.bgGray.whiteBright(str)); } - styleCommand( str : string) { + styleCommand(str: string) { return chalk.greenBright(str); } - styleOption( str : string) { + styleOption(str: string) { return chalk.yellowBright(str); } - stylePrompt( str : string ){ + stylePrompt(str: string) { return chalk.bold(chalk.yellowBright(str)); } - styleNOOP( str : string){ + styleNOOP(str: string) { return chalk.italic(chalk.grey(str)); } - public getStyledHelp(){ - return ` -${this.styleHeader("NAME")} -${this.styleCommand("amplify status")} -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) + public getStyledHelp() { + return ` +${this.styleHeader('NAME')} +${this.styleCommand('amplify status')} -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) -${this.styleHeader("SYNOPSIS")} -${this.styleCommand("amplify status")} [${this.styleCommand("-v")} [${this.styleOption("category ...")}] ] +${this.styleHeader('SYNOPSIS')} +${this.styleCommand('amplify status')} [${this.styleCommand('-v')} [${this.styleOption('category ...')}] ] -${this.styleHeader("DESCRIPTION")} +${this.styleHeader('DESCRIPTION')} The amplify status command displays the difference between the deployed state and the local state of the application. The following options are available: -${this.styleNOOP("no options")} : (Summary mode) Displays the summary of local state vs deployed state of the application -${this.styleCommand("-v [category ...]")} : (Verbose mode) Displays the cloudformation diff for all resources for the specificed category. +${this.styleNOOP('no options')} : (Summary mode) Displays the summary of local state vs deployed state of the application +${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudformation diff for all resources for the specificed category. If no category is provided, it shows the diff for all categories. usage: - ${this.stylePrompt("#\>")} ${this.styleCommand("amplify status -v")} - ${this.stylePrompt("#\>")} ${this.styleCommand("status -v ")}${this.styleOption( "api storage")} + ${this.stylePrompt('#>')} ${this.styleCommand('amplify status -v')} + ${this.stylePrompt('#>')} ${this.styleCommand('amplify status -v ')}${this.styleOption('api storage')} - ` + `; } - public constructor( cliParams : CLIParams ){ - this.command = cliParams.cliCommand; - this.verbose = (cliParams.cliOptions?.verbose === true ); - this.categoryList = this.getCategoryFromCLIOptions( cliParams.cliOptions ); - this.filteredResourceList = []; //TBD - add support to provide resources - this.help = (cliParams.cliSubcommands)?cliParams.cliSubcommands.includes("help"):false; + public constructor(cliParams: CLIParams) { + this._command = cliParams.cliCommand; + this._verbose = cliParams.cliOptions?.verbose === true; + this._categoryList = this.getCategoryFromCLIOptions(cliParams.cliOptions); + this._filteredResourceList = []; //TBD - add support to provide resources + this._help = cliParams.cliSubcommands ? cliParams.cliSubcommands.includes('help') : false; } -} \ No newline at end of file +} diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts new file mode 100644 index 00000000000..f38ff07ccad --- /dev/null +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts @@ -0,0 +1,537 @@ +import _ from 'lodash'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { ServiceName as FunctionServiceName, hashLayerResource } from 'amplify-category-function'; +import { removeGetUserEndpoints } from '../amplify-helpers/remove-pinpoint-policy'; +import { pathManager, stateManager, NotInitializedError } from 'amplify-cli-core'; +import { hashElement, HashElementOptions } from 'folder-hash'; +import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; +import * as resourceStatus from './resource-status-diff'; +import { IResourceDiffCollection } from './resource-status-diff'; +import { ViewResourceTableParams } from 'amplify-cli-core/src/cliViewAPI'; + + +//API: Filter resource status for the given categories +export async function getMultiCategoryStatus(inputs: ViewResourceTableParams | undefined) { + let resourceStatusResults = await getResourceStatus(); + if (inputs?.categoryList?.length) { + //diffs for only the required categories (amplify -v ...) + //TBD: optimize search + resourceStatusResults.resourcesToBeCreated = filterResourceCategory(resourceStatusResults.resourcesToBeCreated, inputs.categoryList); + resourceStatusResults.resourcesToBeUpdated = filterResourceCategory(resourceStatusResults.resourcesToBeUpdated, inputs.categoryList); + resourceStatusResults.resourcesToBeSynced = filterResourceCategory(resourceStatusResults.resourcesToBeSynced, inputs.categoryList); + resourceStatusResults.resourcesToBeDeleted = filterResourceCategory(resourceStatusResults.resourcesToBeDeleted, inputs.categoryList); + resourceStatusResults.allResources = filterResourceCategory(resourceStatusResults.allResources, inputs.categoryList); + } + return resourceStatusResults; +} + +export async function getResourceDiffs(resourcesToBeUpdated, resourcesToBeDeleted, resourcesToBeCreated) { + const result: IResourceDiffCollection = { + updatedDiff: await resourceStatus.CollateResourceDiffs(resourcesToBeUpdated, resourceStatus.stackMutationType.UPDATE), + deletedDiff: await resourceStatus.CollateResourceDiffs(resourcesToBeDeleted, resourceStatus.stackMutationType.DELETE), + createdDiff: await resourceStatus.CollateResourceDiffs(resourcesToBeCreated, resourceStatus.stackMutationType.CREATE), + }; + return result; +} + +export function getSummaryTableData({ + resourcesToBeUpdated, + resourcesToBeDeleted, + resourcesToBeCreated, + resourcesToBeSynced, + allResources, +}) { + let noChangeResources = _.differenceWith( + allResources, + resourcesToBeCreated.concat(resourcesToBeUpdated).concat(resourcesToBeSynced), + _.isEqual, + ); + noChangeResources = noChangeResources.filter(resource => resource.category !== 'providers'); + + const createOperationLabel = 'Create'; + const updateOperationLabel = 'Update'; + const deleteOperationLabel = 'Delete'; + const importOperationLabel = 'Import'; + const unlinkOperationLabel = 'Unlink'; + const noOperationLabel = 'No Change'; + const tableOptions = [['Category', 'Resource name', 'Operation', 'Provider plugin']]; + + for (let i = 0; i < resourcesToBeCreated.length; ++i) { + tableOptions.push([ + capitalize(resourcesToBeCreated[i].category), + resourcesToBeCreated[i].resourceName, + createOperationLabel, + resourcesToBeCreated[i].providerPlugin, + ]); + } + + for (let i = 0; i < resourcesToBeUpdated.length; ++i) { + tableOptions.push([ + capitalize(resourcesToBeUpdated[i].category), + resourcesToBeUpdated[i].resourceName, + updateOperationLabel, + resourcesToBeUpdated[i].providerPlugin, + ]); + } + + for (let i = 0; i < resourcesToBeSynced.length; ++i) { + let operation; + + switch (resourcesToBeSynced[i].sync) { + case 'import': + operation = importOperationLabel; + break; + case 'unlink': + operation = unlinkOperationLabel; + break; + default: + // including refresh + operation = noOperationLabel; + break; + } + + tableOptions.push([ + capitalize(resourcesToBeSynced[i].category), + resourcesToBeSynced[i].resourceName, + operation /*syncOperationLabel*/, + resourcesToBeSynced[i].providerPlugin, + ]); + } + + for (let i = 0; i < resourcesToBeDeleted.length; ++i) { + tableOptions.push([ + capitalize(resourcesToBeDeleted[i].category), + resourcesToBeDeleted[i].resourceName, + deleteOperationLabel, + resourcesToBeDeleted[i].providerPlugin, + ]); + } + + for (let i = 0; i < noChangeResources.length; ++i) { + tableOptions.push([ + capitalize(noChangeResources[i].category), + noChangeResources[i].resourceName, + noOperationLabel, + noChangeResources[i].providerPlugin, + ]); + } + return tableOptions; +} +//API: get resources which need to be created/updated/synced/deleted and associated data (tagUpdated) +export async function getResourceStatus( + category?, + resourceName?, + providerName?, + filteredResources?, +): Promise { + const amplifyProjectInitStatus = getCloudInitStatus(); + let { amplifyMeta, currentAmplifyMeta } = getAmplifyMeta(); + let resourcesToBeCreated: any = getResourcesToBeCreated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); + let resourcesToBeUpdated: any = await getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); + let resourcesToBeSynced: any = getResourcesToBeSynced(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); + let resourcesToBeDeleted: any = getResourcesToBeDeleted(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); + let allResources: any = getAllResources(amplifyMeta, category, resourceName, filteredResources); + + resourcesToBeCreated = resourcesToBeCreated.filter(resource => resource.category !== 'provider'); + + if (providerName) { + resourcesToBeCreated = resourcesToBeCreated.filter(resource => resource.providerPlugin === providerName); + resourcesToBeUpdated = resourcesToBeUpdated.filter(resource => resource.providerPlugin === providerName); + resourcesToBeSynced = resourcesToBeSynced.filter(resource => resource.providerPlugin === providerName); + resourcesToBeDeleted = resourcesToBeDeleted.filter(resource => resource.providerPlugin === providerName); + allResources = allResources.filter(resource => resource.providerPlugin === providerName); + } + // if not equal there is a tag update + const tagsUpdated = !_.isEqual(stateManager.getProjectTags(), stateManager.getCurrentProjectTags()); + + return { + resourcesToBeCreated, + resourcesToBeUpdated, + resourcesToBeSynced, + resourcesToBeDeleted, + tagsUpdated, + allResources, + }; +} + +export function getAllResources(amplifyMeta, category, resourceName, filteredResources) { + let resources: any[] = []; + + Object.keys(amplifyMeta).forEach(categoryName => { + const categoryItem = amplifyMeta[categoryName]; + Object.keys(categoryItem).forEach(resource => { + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + resources.push(amplifyMeta[categoryName][resource]); + }); + }); + + resources = filterResources(resources, filteredResources); + + if (category !== undefined && resourceName !== undefined) { + // Create only specified resource in the cloud + resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); + } + + if (category !== undefined && !resourceName) { + // Create all the resources for the specified category in the cloud + resources = resources.filter(resource => resource.category === category); + } + + return resources; +} + +export function getResourcesToBeCreated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { + let resources: any[] = []; + + Object.keys(amplifyMeta).forEach(categoryName => { + const categoryItem = amplifyMeta[categoryName]; + Object.keys(categoryItem).forEach(resource => { + if ( + (!amplifyMeta[categoryName][resource].lastPushTimeStamp || + !currentAmplifyMeta[categoryName] || + !currentAmplifyMeta[categoryName][resource]) && + categoryName !== 'providers' && + amplifyMeta[categoryName][resource].serviceType !== 'imported' + ) { + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + resources.push(amplifyMeta[categoryName][resource]); + } + }); + }); + + resources = filterResources(resources, filteredResources); + + if (category !== undefined && resourceName !== undefined) { + // Create only specified resource in the cloud + resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); + } + + if (category !== undefined && !resourceName) { + // Create all the resources for the specified category in the cloud + resources = resources.filter(resource => resource.category === category); + } + + // Check for dependencies and add them + + for (let i = 0; i < resources.length; ++i) { + if (resources[i].dependsOn && resources[i].dependsOn.length > 0) { + for (let j = 0; j < resources[i].dependsOn.length; ++j) { + const dependsOnCategory = resources[i].dependsOn[j].category; + const dependsOnResourcename = resources[i].dependsOn[j].resourceName; + if ( + amplifyMeta[dependsOnCategory] && + (!amplifyMeta[dependsOnCategory][dependsOnResourcename].lastPushTimeStamp || + !currentAmplifyMeta[dependsOnCategory] || + !currentAmplifyMeta[dependsOnCategory][dependsOnResourcename]) && + amplifyMeta[dependsOnCategory][dependsOnResourcename].serviceType !== 'imported' + ) { + resources.push(amplifyMeta[dependsOnCategory][dependsOnResourcename]); + } + } + } + } + + return _.uniqWith(resources, _.isEqual); +} + +export function getResourcesToBeDeleted(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { + let resources: any[] = []; + + Object.keys(currentAmplifyMeta).forEach(categoryName => { + const categoryItem = currentAmplifyMeta[categoryName]; + Object.keys(categoryItem).forEach(resource => { + if ((!amplifyMeta[categoryName] || !amplifyMeta[categoryName][resource]) && categoryItem[resource].serviceType !== 'imported') { + currentAmplifyMeta[categoryName][resource].resourceName = resource; + currentAmplifyMeta[categoryName][resource].category = categoryName; + + resources.push(currentAmplifyMeta[categoryName][resource]); + } + }); + }); + + resources = filterResources(resources, filteredResources); + + if (category !== undefined && resourceName !== undefined) { + // Deletes only specified resource in the cloud + resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); + } + + if (category !== undefined && !resourceName) { + // Deletes all the resources for the specified category in the cloud + resources = resources.filter(resource => resource.category === category); + } + + return resources; +} + +export async function getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { + let resources: any[] = []; + + await asyncForEach(Object.keys(amplifyMeta), async categoryName => { + const categoryItem = amplifyMeta[categoryName]; + await asyncForEach(Object.keys(categoryItem), async resource => { + if (categoryName === 'analytics') { + removeGetUserEndpoints(resource); + } + + if ( + currentAmplifyMeta[categoryName] && + currentAmplifyMeta[categoryName][resource] !== undefined && + amplifyMeta[categoryName] && + amplifyMeta[categoryName][resource] !== undefined && + amplifyMeta[categoryName][resource].serviceType !== 'imported' + ) { + if (categoryName === 'function' && currentAmplifyMeta[categoryName][resource].service === FunctionServiceName.LambdaLayer) { + const backendModified = await isBackendDirModifiedSinceLastPush( + resource, + categoryName, + currentAmplifyMeta[categoryName][resource].lastPushTimeStamp, + hashLayerResource, + ); + + if (backendModified) { + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + resources.push(amplifyMeta[categoryName][resource]); + } + } else { + const backendModified = await isBackendDirModifiedSinceLastPush( + resource, + categoryName, + currentAmplifyMeta[categoryName][resource].lastPushTimeStamp, + getHashForResourceDir, + ); + + if (backendModified) { + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + resources.push(amplifyMeta[categoryName][resource]); + } + + if (categoryName === 'hosting' && currentAmplifyMeta[categoryName][resource].service === 'ElasticContainer') { + const { + frontend, + [frontend]: { + config: { SourceDir }, + }, + } = stateManager.getProjectConfig(); + // build absolute path for Dockerfile and docker-compose.yaml + const projectRootPath = pathManager.findProjectRoot(); + if (projectRootPath) { + const sourceAbsolutePath = path.join(projectRootPath, SourceDir); + + // Generate the hash for this file, cfn files are autogenerated based on Dockerfile and resource settings + // Hash is generated by this files and not cfn files + const dockerfileHash = await getHashForResourceDir(sourceAbsolutePath, [ + 'Dockerfile', + 'docker-compose.yaml', + 'docker-compose.yml', + ]); + + // Compare hash with value stored on meta + if (currentAmplifyMeta[categoryName][resource].lastPushDirHash !== dockerfileHash) { + resources.push(amplifyMeta[categoryName][resource]); + return; + } + } + } + } + } + }); + }); + + resources = filterResources(resources, filteredResources); + + if (category !== undefined && resourceName !== undefined) { + resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); + } + if (category !== undefined && !resourceName) { + resources = resources.filter(resource => resource.category === category); + } + return resources; +} + +export function getResourcesToBeSynced(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { + let resources: any[] = []; + + // For imported resource we are handling add/remove/delete in one place, because + // it does not involve CFN operations we still need a way to enforce the CLI + // to show changes status when imported resources are added or removed + + Object.keys(amplifyMeta).forEach(categoryName => { + const categoryItem = amplifyMeta[categoryName]; + + Object.keys(categoryItem) + .filter(resource => categoryItem[resource].serviceType === 'imported') + .forEach(resource => { + // Added + if ( + _.get(currentAmplifyMeta, [categoryName, resource], undefined) === undefined && + _.get(amplifyMeta, [categoryName, resource], undefined) !== undefined + ) { + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + amplifyMeta[categoryName][resource].sync = 'import'; + + resources.push(amplifyMeta[categoryName][resource]); + } else if ( + _.get(currentAmplifyMeta, [categoryName, resource], undefined) !== undefined && + _.get(amplifyMeta, [categoryName, resource], undefined) === undefined + ) { + // Removed + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + amplifyMeta[categoryName][resource].sync = 'unlink'; + + resources.push(amplifyMeta[categoryName][resource]); + } else if ( + _.get(currentAmplifyMeta, [categoryName, resource], undefined) !== undefined && + _.get(amplifyMeta, [categoryName, resource], undefined) !== undefined + ) { + // Refresh - for resources that are already present, it is possible that secrets needed to be + // regenerated or any other data needs to be refreshed, it is a special state for imported resources + // and only need to be handled in env add, but no different status is being printed in status + amplifyMeta[categoryName][resource].resourceName = resource; + amplifyMeta[categoryName][resource].category = categoryName; + amplifyMeta[categoryName][resource].sync = 'refresh'; + + resources.push(amplifyMeta[categoryName][resource]); + } + }); + }); + + // For remove it is possible that the the object key for the category not present in the meta so an extra iteration needed on + // currentAmplifyMeta keys as well + + Object.keys(currentAmplifyMeta).forEach(categoryName => { + const categoryItem = currentAmplifyMeta[categoryName]; + + Object.keys(categoryItem) + .filter(resource => categoryItem[resource].serviceType === 'imported') + .forEach(resource => { + // Removed + if ( + _.get(currentAmplifyMeta, [categoryName, resource], undefined) !== undefined && + _.get(amplifyMeta, [categoryName, resource], undefined) === undefined + ) { + currentAmplifyMeta[categoryName][resource].resourceName = resource; + currentAmplifyMeta[categoryName][resource].category = categoryName; + currentAmplifyMeta[categoryName][resource].sync = 'unlink'; + + resources.push(currentAmplifyMeta[categoryName][resource]); + } + }); + }); + + resources = filterResources(resources, filteredResources); + + if (category !== undefined && resourceName !== undefined) { + resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); + } + + if (category !== undefined && !resourceName) { + resources = resources.filter(resource => resource.category === category); + } + + return resources; +} + +//API: get amplify metadata based on cloud-init status. +export function getAmplifyMeta() { + const amplifyProjectInitStatus = getCloudInitStatus(); + if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { + return { + amplifyMeta: stateManager.getMeta(), + currentAmplifyMeta: stateManager.getCurrentMeta(), + }; + } else if (amplifyProjectInitStatus === CLOUD_NOT_INITIALIZED) { + return { + amplifyMeta: stateManager.getBackendConfig(), + currentAmplifyMeta: {}, + }; + } else { + throw new NotInitializedError(); + } +} + +//helper: Check if directory has been modified by comparing hash values +async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPushTimeStamp, hashFunction) { + // Pushing the resource for the first time hence no lastPushTimeStamp + if (!lastPushTimeStamp) { + return false; + } + const localBackendDir = path.normalize(path.join(pathManager.getBackendDirPath(), category, resourceName)); + const cloudBackendDir = path.normalize(path.join(pathManager.getCurrentCloudBackendDirPath(), category, resourceName)); + + if (!fs.existsSync(localBackendDir)) { + return false; + } + + const localDirHash = await hashFunction(localBackendDir, resourceName); + const cloudDirHash = await hashFunction(cloudBackendDir, resourceName); + + return localDirHash !== cloudDirHash; +} + +//API: calculate hash for resource directory : TBD move to library +export function getHashForResourceDir(dirPath, files?: string[]) { + const options: HashElementOptions = { + folders: { exclude: ['.*', 'node_modules', 'test_coverage', 'dist', 'build'] }, + files: { + include: files, + }, + }; + return hashElement(dirPath, options).then(result => result.hash); +} + +//helper: remove specificed resources from list of given resources +function filterResources(resources, filteredResources) { + if (!filteredResources) { + return resources; + } + resources = resources.filter(resource => { + let common = false; + for (let i = 0; i < filteredResources.length; ++i) { + if (filteredResources[i].category === resource.category && filteredResources[i].resourceName === resource.resourceName) { + common = true; + break; + } + } + return common; + }); + return resources; +} +//helper: validate category of the resource +function resourceBelongsToCategoryList(category, categoryList) { + if (typeof category === 'string') { + return categoryList.includes(category); + } else { + return false; + } +} +//helper: filter resources based on category +function filterResourceCategory(resourceList, categoryList) { + return resourceList ? resourceList.filter(resource => resourceBelongsToCategoryList(resource.category, categoryList)) : []; +} + +//Get the name of the AWS service provisioning the resource +export function getResourceService(category: string, resourceName: string) { + let { amplifyMeta } = getAmplifyMeta(); + const categoryMeta = amplifyMeta ? amplifyMeta[category] : {}; + return categoryMeta[resourceName]?.service; +} + +//helper to await results of all function calls +//TODO: replace with 'await for of' +async function asyncForEach(array, callback) { + for (let index = 0; index < array.length; ++index) { + await callback(array[index], index, array); + } +} +//TODO: replace with some library function +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 9e2ea4acbb9..4df53c54915 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -1,13 +1,12 @@ import * as fs from 'fs-extra'; import * as path from 'path'; +import * as glob from 'glob'; +import chalk from 'chalk'; import * as cfnDiff from '@aws-cdk/cloudformation-diff'; import { print } from './print'; import { pathManager, readCFNTemplate } from 'amplify-cli-core'; -import chalk from 'chalk'; -import { getResourceService } from './resource-status'; -import * as glob from 'glob'; - +import { getResourceService } from './resource-status-data'; const CategoryProviders = { CLOUDFORMATION : "cloudformation", @@ -159,7 +158,7 @@ export class ResourceDiff { //helper: wrapper around readCFNTemplate type to handle expressions. - safeReadCFNTemplate = async(filePath : string ) => { + private safeReadCFNTemplate = async(filePath : string ) => { try { const templateResult = await readCFNTemplate(filePath); return templateResult.cfnTemplate; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts new file mode 100644 index 00000000000..04d917e38d0 --- /dev/null +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts @@ -0,0 +1,39 @@ +import { getSummaryTableData, getResourceDiffs } from './resource-status-data'; +import * as resourceStatus from './resource-status-diff'; +import { getEnvInfo } from './get-env-info'; +import { print } from './print'; +import chalk from 'chalk'; +//view: displays resource-diff (cloudformation-diff, input parameters (pending)) +export async function viewResourceDiffs({ resourcesToBeUpdated, resourcesToBeDeleted, resourcesToBeCreated }) { + const resourceDiffs = await getResourceDiffs(resourcesToBeUpdated, resourcesToBeDeleted, resourcesToBeCreated); + for await (let resourceDiff of resourceDiffs.updatedDiff) { + //Print with UPDATE styling theme + resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.UPDATE); + } + print.info('\n'); + for await (let resourceDiff of resourceDiffs.deletedDiff) { + //Print with DELETE styling theme + resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.DELETE); + } + print.info('\n'); + for await (let resourceDiff of resourceDiffs.createdDiff) { + //Print with CREATE styling theme + resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.CREATE); + } + print.info('\n'); +} + +//view: displays environment specific info +export function viewEnvInfo() { + const { envName } = getEnvInfo(); + print.info(` + ${chalk.green('Current Environment')}: ${envName} + `); +} + +//view: displays status-summary table +export function viewSummaryTable(resourceStateData) { + const tableOptions = getSummaryTableData(resourceStateData); + const { table } = print; + table(tableOptions, { format: 'lean' }); +} diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index 6db5d59ab0c..0d216247355 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -1,595 +1,13 @@ -import * as fs from 'fs-extra'; -import * as path from 'path'; -import chalk from 'chalk'; -import _ from 'lodash'; -import { print } from './print'; -import { hashElement, HashElementOptions } from 'folder-hash'; -import { getEnvInfo } from './get-env-info'; -import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; -import { ServiceName as FunctionServiceName, hashLayerResource } from 'amplify-category-function'; -import { removeGetUserEndpoints } from '../amplify-helpers/remove-pinpoint-policy'; -import { pathManager, stateManager, $TSMeta, $TSAny, NotInitializedError } from 'amplify-cli-core'; -import { ViewResourceTableParams } from "amplify-cli-core/lib/cliViewAPI"; -import * as resourceStatus from './resource-status-diff'; -import { ResourceDiff, IResourceDiffCollection, ICategoryStatusCollection } from './resource-status-diff'; -import { getAmplifyMetaFilePath } from './path-manager'; - - -async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPushTimeStamp, hashFunction) { - // Pushing the resource for the first time hence no lastPushTimeStamp - if (!lastPushTimeStamp) { - return false; - } - const localBackendDir = path.normalize(path.join(pathManager.getBackendDirPath(), category, resourceName)); - const cloudBackendDir = path.normalize(path.join(pathManager.getCurrentCloudBackendDirPath(), category, resourceName)); - - if (!fs.existsSync(localBackendDir)) { - return false; - } - - const localDirHash = await hashFunction(localBackendDir, resourceName); - const cloudDirHash = await hashFunction(cloudBackendDir, resourceName); - - return localDirHash !== cloudDirHash; -} - -export function getHashForResourceDir(dirPath, files?: string[]) { - const options: HashElementOptions = { - folders: { exclude: ['.*', 'node_modules', 'test_coverage', 'dist', 'build'] }, - files: { - include: files, - }, - }; - - return hashElement(dirPath, options).then(result => result.hash); -} - -function capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); -} - -function filterResources(resources, filteredResources) { - if (!filteredResources) { - return resources; - } - - resources = resources.filter(resource => { - let common = false; - for (let i = 0; i < filteredResources.length; ++i) { - if (filteredResources[i].category === resource.category && filteredResources[i].resourceName === resource.resourceName) { - common = true; - break; - } - } - return common; - }); - - return resources; -} - -function getAllResources(amplifyMeta, category, resourceName, filteredResources) { - let resources: any[] = []; - - Object.keys(amplifyMeta).forEach(categoryName => { - const categoryItem = amplifyMeta[categoryName]; - Object.keys(categoryItem).forEach(resource => { - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - resources.push(amplifyMeta[categoryName][resource]); - }); - }); - - resources = filterResources(resources, filteredResources); - - if (category !== undefined && resourceName !== undefined) { - // Create only specified resource in the cloud - resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); - } - - if (category !== undefined && !resourceName) { - // Create all the resources for the specified category in the cloud - resources = resources.filter(resource => resource.category === category); - } - - return resources; -} - -function getResourcesToBeCreated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { - let resources: any[] = []; - - Object.keys(amplifyMeta).forEach(categoryName => { - const categoryItem = amplifyMeta[categoryName]; - Object.keys(categoryItem).forEach(resource => { - if ( - (!amplifyMeta[categoryName][resource].lastPushTimeStamp || - !currentAmplifyMeta[categoryName] || - !currentAmplifyMeta[categoryName][resource]) && - categoryName !== 'providers' && - amplifyMeta[categoryName][resource].serviceType !== 'imported' - ) { - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - resources.push(amplifyMeta[categoryName][resource]); - } - }); - }); - - resources = filterResources(resources, filteredResources); - - if (category !== undefined && resourceName !== undefined) { - // Create only specified resource in the cloud - resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); - } - - if (category !== undefined && !resourceName) { - // Create all the resources for the specified category in the cloud - resources = resources.filter(resource => resource.category === category); - } - - // Check for dependencies and add them - - for (let i = 0; i < resources.length; ++i) { - if (resources[i].dependsOn && resources[i].dependsOn.length > 0) { - for (let j = 0; j < resources[i].dependsOn.length; ++j) { - const dependsOnCategory = resources[i].dependsOn[j].category; - const dependsOnResourcename = resources[i].dependsOn[j].resourceName; - if ( - amplifyMeta[dependsOnCategory] && - (!amplifyMeta[dependsOnCategory][dependsOnResourcename].lastPushTimeStamp || - !currentAmplifyMeta[dependsOnCategory] || - !currentAmplifyMeta[dependsOnCategory][dependsOnResourcename]) && - amplifyMeta[dependsOnCategory][dependsOnResourcename].serviceType !== 'imported' - ) { - resources.push(amplifyMeta[dependsOnCategory][dependsOnResourcename]); - } - } - } - } - - return _.uniqWith(resources, _.isEqual); -} - -function getResourcesToBeDeleted(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { - let resources: any[] = []; - - Object.keys(currentAmplifyMeta).forEach(categoryName => { - const categoryItem = currentAmplifyMeta[categoryName]; - Object.keys(categoryItem).forEach(resource => { - if ((!amplifyMeta[categoryName] || !amplifyMeta[categoryName][resource]) && categoryItem[resource].serviceType !== 'imported') { - currentAmplifyMeta[categoryName][resource].resourceName = resource; - currentAmplifyMeta[categoryName][resource].category = categoryName; - - resources.push(currentAmplifyMeta[categoryName][resource]); - } - }); - }); - - resources = filterResources(resources, filteredResources); - - if (category !== undefined && resourceName !== undefined) { - // Deletes only specified resource in the cloud - resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); - } - - if (category !== undefined && !resourceName) { - // Deletes all the resources for the specified category in the cloud - resources = resources.filter(resource => resource.category === category); - } - - return resources; -} - -async function getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { - let resources: any[] = []; - - await asyncForEach(Object.keys(amplifyMeta), async categoryName => { - const categoryItem = amplifyMeta[categoryName]; - await asyncForEach(Object.keys(categoryItem), async resource => { - if (categoryName === 'analytics') { - removeGetUserEndpoints(resource); - } - - if ( - currentAmplifyMeta[categoryName] && - currentAmplifyMeta[categoryName][resource] !== undefined && - amplifyMeta[categoryName] && - amplifyMeta[categoryName][resource] !== undefined && - amplifyMeta[categoryName][resource].serviceType !== 'imported' - ) { - if (categoryName === 'function' && currentAmplifyMeta[categoryName][resource].service === FunctionServiceName.LambdaLayer) { - const backendModified = await isBackendDirModifiedSinceLastPush( - resource, - categoryName, - currentAmplifyMeta[categoryName][resource].lastPushTimeStamp, - hashLayerResource, - ); - - if (backendModified) { - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - resources.push(amplifyMeta[categoryName][resource]); - } - } else { - const backendModified = await isBackendDirModifiedSinceLastPush( - resource, - categoryName, - currentAmplifyMeta[categoryName][resource].lastPushTimeStamp, - getHashForResourceDir, - ); - - if (backendModified) { - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - resources.push(amplifyMeta[categoryName][resource]); - } - - if (categoryName === 'hosting' && currentAmplifyMeta[categoryName][resource].service === 'ElasticContainer') { - const { - frontend, - [frontend]: { - config: { SourceDir }, - }, - } = stateManager.getProjectConfig(); - // build absolute path for Dockerfile and docker-compose.yaml - const projectRootPath = pathManager.findProjectRoot(); - if (projectRootPath) { - const sourceAbsolutePath = path.join(projectRootPath, SourceDir); - - // Generate the hash for this file, cfn files are autogenerated based on Dockerfile and resource settings - // Hash is generated by this files and not cfn files - const dockerfileHash = await getHashForResourceDir(sourceAbsolutePath, [ - 'Dockerfile', - 'docker-compose.yaml', - 'docker-compose.yml', - ]); - - // Compare hash with value stored on meta - if (currentAmplifyMeta[categoryName][resource].lastPushDirHash !== dockerfileHash) { - resources.push(amplifyMeta[categoryName][resource]); - return; - } - } - } - } - } - }); - }); - - resources = filterResources(resources, filteredResources); - - if (category !== undefined && resourceName !== undefined) { - resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); - } - if (category !== undefined && !resourceName) { - resources = resources.filter(resource => resource.category === category); - } - return resources; -} - -function getResourcesToBeSynced(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources) { - let resources: any[] = []; - // For imported resource we are handling add/remove/delete in one place, because - // it does not involve CFN operations we still need a way to enforce the CLI - // to show changes status when imported resources are added or removed - Object.keys(amplifyMeta).forEach(categoryName => { - const categoryItem = amplifyMeta[categoryName]; - - Object.keys(categoryItem) - .filter(resource => categoryItem[resource].serviceType === 'imported') - .forEach(resource => { - // Added - if ( - _.get(currentAmplifyMeta, [categoryName, resource], undefined) === undefined && - _.get(amplifyMeta, [categoryName, resource], undefined) !== undefined - ) { - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - amplifyMeta[categoryName][resource].sync = 'import'; - - resources.push(amplifyMeta[categoryName][resource]); - } else if ( - _.get(currentAmplifyMeta, [categoryName, resource], undefined) !== undefined && - _.get(amplifyMeta, [categoryName, resource], undefined) === undefined - ) { - // Removed - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - amplifyMeta[categoryName][resource].sync = 'unlink'; - - resources.push(amplifyMeta[categoryName][resource]); - } else if ( - _.get(currentAmplifyMeta, [categoryName, resource], undefined) !== undefined && - _.get(amplifyMeta, [categoryName, resource], undefined) !== undefined - ) { - // Refresh - for resources that are already present, it is possible that secrets needed to be - // regenerated or any other data needs to be refreshed, it is a special state for imported resources - // and only need to be handled in env add, but no different status is being printed in status - amplifyMeta[categoryName][resource].resourceName = resource; - amplifyMeta[categoryName][resource].category = categoryName; - amplifyMeta[categoryName][resource].sync = 'refresh'; - - resources.push(amplifyMeta[categoryName][resource]); - } - }); - }); - - // For remove it is possible that the the object key for the category not present in the meta so an extra iteration needed on - // currentAmplifyMeta keys as well - - Object.keys(currentAmplifyMeta).forEach(categoryName => { - const categoryItem = currentAmplifyMeta[categoryName]; - - Object.keys(categoryItem) - .filter(resource => categoryItem[resource].serviceType === 'imported') - .forEach(resource => { - // Removed - if ( - _.get(currentAmplifyMeta, [categoryName, resource], undefined) !== undefined && - _.get(amplifyMeta, [categoryName, resource], undefined) === undefined - ) { - currentAmplifyMeta[categoryName][resource].resourceName = resource; - currentAmplifyMeta[categoryName][resource].category = categoryName; - currentAmplifyMeta[categoryName][resource].sync = 'unlink'; - - resources.push(currentAmplifyMeta[categoryName][resource]); - } - }); - }); - - resources = filterResources(resources, filteredResources); - - if (category !== undefined && resourceName !== undefined) { - resources = resources.filter(resource => resource.category === category && resource.resourceName === resourceName); - } - - if (category !== undefined && !resourceName) { - resources = resources.filter(resource => resource.category === category); - } - - return resources; -} - -async function asyncForEach(array, callback) { - for (let index = 0; index < array.length; ++index) { - await callback(array[index], index, array); - } -} -export function getAmplifyMeta(){ - const amplifyProjectInitStatus = getCloudInitStatus(); - if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { - return { - amplifyMeta : stateManager.getMeta(), - currentAmplifyMeta : stateManager.getCurrentMeta() - } - } else if (amplifyProjectInitStatus === CLOUD_NOT_INITIALIZED) { - return { - amplifyMeta : stateManager.getBackendConfig(), - currentAmplifyMeta : {} - } - } else { - throw new NotInitializedError(); - } -} - -//Get the name of the AWS service provisioning the resource -export function getResourceService( category: string, resourceName: string){ - let { amplifyMeta } = getAmplifyMeta(); - const categoryMeta = (amplifyMeta)?amplifyMeta[category]:{}; - return categoryMeta[resourceName]?.service; -} - -export async function getResourceStatus(category?, resourceName?, providerName?, filteredResources?): Promise{ - - const amplifyProjectInitStatus = getCloudInitStatus(); - let { amplifyMeta, currentAmplifyMeta } = getAmplifyMeta(); - let resourcesToBeCreated: any = getResourcesToBeCreated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); - let resourcesToBeUpdated: any = await getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); - let resourcesToBeSynced: any = getResourcesToBeSynced(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); - let resourcesToBeDeleted: any = getResourcesToBeDeleted(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); - let allResources: any = getAllResources(amplifyMeta, category, resourceName, filteredResources); - - resourcesToBeCreated = resourcesToBeCreated.filter(resource => resource.category !== 'provider'); - - if (providerName) { - resourcesToBeCreated = resourcesToBeCreated.filter(resource => resource.providerPlugin === providerName); - resourcesToBeUpdated = resourcesToBeUpdated.filter(resource => resource.providerPlugin === providerName); - resourcesToBeSynced = resourcesToBeSynced.filter(resource => resource.providerPlugin === providerName); - resourcesToBeDeleted = resourcesToBeDeleted.filter(resource => resource.providerPlugin === providerName); - allResources = allResources.filter(resource => resource.providerPlugin === providerName); - } - // if not equal there is a tag update - const tagsUpdated = !_.isEqual(stateManager.getProjectTags(), stateManager.getCurrentProjectTags()); - - return { - resourcesToBeCreated, - resourcesToBeUpdated, - resourcesToBeSynced, - resourcesToBeDeleted, - tagsUpdated, - allResources, - }; -} - - -async function getResourceDiffs ( resourcesToBeUpdated, - resourcesToBeDeleted, - resourcesToBeCreated) { - const result : IResourceDiffCollection = { - updatedDiff : await resourceStatus.CollateResourceDiffs( resourcesToBeUpdated , resourceStatus.stackMutationType.UPDATE), - deletedDiff : await resourceStatus.CollateResourceDiffs( resourcesToBeDeleted , resourceStatus.stackMutationType.DELETE), - createdDiff : await resourceStatus.CollateResourceDiffs( resourcesToBeCreated , resourceStatus.stackMutationType.CREATE) - } - return result; -} - -function getSummaryTableData({ resourcesToBeUpdated, - resourcesToBeDeleted, - resourcesToBeCreated, - resourcesToBeSynced, - allResources } ){ - - let noChangeResources = _.differenceWith( - allResources, - resourcesToBeCreated.concat(resourcesToBeUpdated).concat(resourcesToBeSynced), - _.isEqual, - ); - noChangeResources = noChangeResources.filter(resource => resource.category !== 'providers'); - - const createOperationLabel = 'Create'; - const updateOperationLabel = 'Update'; - const deleteOperationLabel = 'Delete'; - const importOperationLabel = 'Import'; - const unlinkOperationLabel = 'Unlink'; - const noOperationLabel = 'No Change'; - const tableOptions = [['Category', 'Resource name', 'Operation', 'Provider plugin']]; - - for (let i = 0; i < resourcesToBeCreated.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeCreated[i].category), - resourcesToBeCreated[i].resourceName, - createOperationLabel, - resourcesToBeCreated[i].providerPlugin, - ]); - } - - for (let i = 0; i < resourcesToBeUpdated.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeUpdated[i].category), - resourcesToBeUpdated[i].resourceName, - updateOperationLabel, - resourcesToBeUpdated[i].providerPlugin, - ]); - } - - for (let i = 0; i < resourcesToBeSynced.length; ++i) { - let operation; - - switch (resourcesToBeSynced[i].sync) { - case 'import': - operation = importOperationLabel; - break; - case 'unlink': - operation = unlinkOperationLabel; - break; - default: - // including refresh - operation = noOperationLabel; - break; - } - - tableOptions.push([ - capitalize(resourcesToBeSynced[i].category), - resourcesToBeSynced[i].resourceName, - operation /*syncOperationLabel*/, - resourcesToBeSynced[i].providerPlugin, - ]); - } - - for (let i = 0; i < resourcesToBeDeleted.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeDeleted[i].category), - resourcesToBeDeleted[i].resourceName, - deleteOperationLabel, - resourcesToBeDeleted[i].providerPlugin, - ]); - } - - for (let i = 0; i < noChangeResources.length; ++i) { - tableOptions.push([ - capitalize(noChangeResources[i].category), - noChangeResources[i].resourceName, - noOperationLabel, - noChangeResources[i].providerPlugin, - ]); - } - return tableOptions; -} - - -async function viewResourceDiffs( { resourcesToBeUpdated, - resourcesToBeDeleted, - resourcesToBeCreated - } ){ - const resourceDiffs = await getResourceDiffs ( resourcesToBeUpdated, - resourcesToBeDeleted, - resourcesToBeCreated ); - for await ( let resourceDiff of resourceDiffs.updatedDiff){ - //Print with UPDATE styling theme - resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.UPDATE); - } - print.info("\n"); - for await (let resourceDiff of resourceDiffs.deletedDiff){ - //Print with DELETE styling theme - resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.DELETE); - } - print.info("\n"); - for await (let resourceDiff of resourceDiffs.createdDiff){ - //Print with CREATE styling theme - resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.CREATE); - } - print.info("\n"); -} - -function viewEnvInfo(){ - const { envName } = getEnvInfo(); - print.info(` - ${chalk.green('Current Environment')}: ${envName} - `); -} - -function viewSummaryTable( resourceStateData ){ - const tableOptions = getSummaryTableData( resourceStateData ) - const { table } = print; - table(tableOptions, { format: 'lean' }); -} - -//Helper function to merge multi-category status -function mergeMultiCategoryStatus( accumulator : ICategoryStatusCollection, currentObject : ICategoryStatusCollection ){ - if ( !accumulator ){ - return currentObject; - } else if (!currentObject ){ - return accumulator; - } else { - let mergedResult :ICategoryStatusCollection = { - resourcesToBeCreated: accumulator.resourcesToBeCreated.concat(currentObject.resourcesToBeCreated), - resourcesToBeUpdated: accumulator.resourcesToBeUpdated.concat(currentObject.resourcesToBeUpdated), - resourcesToBeDeleted: accumulator.resourcesToBeDeleted.concat(currentObject.resourcesToBeDeleted), - resourcesToBeSynced: accumulator.resourcesToBeSynced.concat(currentObject.resourcesToBeSynced), - allResources:accumulator.allResources.concat(currentObject.allResources), - tagsUpdated: accumulator.tagsUpdated || currentObject.tagsUpdated - } - return mergedResult; - } -} - - -function resourceBelongsToCategoryList( category , categoryList ){ - if( typeof category === 'string'){ - return categoryList.includes( category ) - } else { - return false; - } -} -function filterResourceCategory( resourceList , categoryList){ - return (resourceList)? resourceList.filter(resource => resourceBelongsToCategoryList(resource.category, categoryList)) : [] -} -//Filter resource status for the given categories -export async function getMultiCategoryStatus( inputs: ViewResourceTableParams | undefined ){ - let resourceStatusResults = await getResourceStatus(); - if ( inputs?.categoryList?.length ){ - //diffs for only the required categories (amplify -v ...) - //TBD: optimize search - resourceStatusResults.resourcesToBeCreated = filterResourceCategory(resourceStatusResults.resourcesToBeCreated, inputs.categoryList); - resourceStatusResults.resourcesToBeUpdated = filterResourceCategory(resourceStatusResults.resourcesToBeUpdated, inputs.categoryList); - resourceStatusResults.resourcesToBeSynced = filterResourceCategory(resourceStatusResults.resourcesToBeSynced, inputs.categoryList); - resourceStatusResults.resourcesToBeDeleted = filterResourceCategory(resourceStatusResults.resourcesToBeDeleted, inputs.categoryList); - resourceStatusResults.allResources = filterResourceCategory(resourceStatusResults.allResources, inputs.categoryList) - } - return resourceStatusResults; -} +import { print } from './print'; +import { CLOUD_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; +import { ViewResourceTableParams } from "amplify-cli-core/lib/cliViewAPI"; +import { viewSummaryTable, viewEnvInfo, viewResourceDiffs } from './resource-status-view'; +import { getMultiCategoryStatus, getResourceStatus, getHashForResourceDir } from './resource-status-data'; +import chalk from 'chalk'; +export { getResourceStatus, getHashForResourceDir } export async function showStatusTable( tableViewFilter : ViewResourceTableParams ){ const amplifyProjectInitStatus = getCloudInitStatus(); @@ -633,8 +51,6 @@ export async function showStatusTable( tableViewFilter : ViewResourceTableParams return resourceChanged; } - - export async function showResourceTable(category, resourceName, filteredResources) { //Prepare state for view diff --git a/packages/amplify-e2e-core/src/init/amplifyPush.ts b/packages/amplify-e2e-core/src/init/amplifyPush.ts index 7f96ab68a1d..0364da85b06 100644 --- a/packages/amplify-e2e-core/src/init/amplifyPush.ts +++ b/packages/amplify-e2e-core/src/init/amplifyPush.ts @@ -11,6 +11,17 @@ export type LayerPushSettings = { export function amplifyPush(cwd: string, testingWithLatestCodebase: boolean = false): Promise { return new Promise((resolve, reject) => { + //Test detailed status + spawn(getCLIPath(testingWithLatestCodebase), ['status -v'], { cwd, stripColors: true, noOutputTimeout: pushTimeoutMS }) + .wait(/.*/) + .run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); + //Test amplify push spawn(getCLIPath(testingWithLatestCodebase), ['push'], { cwd, stripColors: true, noOutputTimeout: pushTimeoutMS }) .wait('Are you sure you want to continue?') .sendConfirmYes() From 1db112ba6cf4b5f765b7e9a1752880620a266108 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Mon, 12 Jul 2021 01:21:55 -0700 Subject: [PATCH 11/26] Added types to template, diff --- packages/amplify-cli/src/commands/status.ts | 1 - .../amplify-helpers/resource-status-diff.ts | 11 ++++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index 91389901d41..cc75eccb523 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -1,4 +1,3 @@ -import { constructCloudWatchEventComponent } from "amplify-category-function/src/provider-utils/awscloudformation/utils/cloudformationHelpers"; import { ViewResourceTableParams, CLIParams } from "amplify-cli-core/lib/cliViewAPI"; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 4df53c54915..b15882458a1 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -6,6 +6,7 @@ import chalk from 'chalk'; import * as cfnDiff from '@aws-cdk/cloudformation-diff'; import { print } from './print'; import { pathManager, readCFNTemplate } from 'amplify-cli-core'; +import { Template } from 'cloudform-types'; import { getResourceService } from './resource-status-data'; const CategoryProviders = { @@ -104,12 +105,8 @@ export class ResourceDiff { resourceFiles : IResourcePaths; localBackendDir : string; cloudBackendDir : string; - localTemplate : { - [key: string]: any; - }; - cloudTemplate : { - [key: string]: any; - }; + localTemplate : Template; + cloudTemplate : Template; constructor( category, resourceName, provider ){ this.localBackendDir = pathManager.getBackendDirPath(); @@ -188,7 +185,7 @@ export class ResourceDiff { } //helper: Convert cloudformation template diff data using CDK api - private printStackDiff = ( templateDiff, stream?: cfnDiff.FormatStream ) =>{ + private printStackDiff = ( templateDiff : cfnDiff.TemplateDiff, stream?: cfnDiff.FormatStream ) =>{ // filter out 'AWS::CDK::Metadata' since info is not helpful and formatDifferences doesnt know how to format it. if (templateDiff.resources ) { templateDiff.resources = templateDiff.resources.filter(change => { From a256d18e50c9d35d6b94b690fc5f77ad073f4608 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Mon, 12 Jul 2021 11:01:03 -0700 Subject: [PATCH 12/26] addressed more PR comments --- packages/amplify-cli-core/src/index.ts | 1 + packages/amplify-cli/src/commands/status.ts | 2 +- .../src/extensions/amplify-helpers/resource-status-data.ts | 1 - .../src/extensions/amplify-helpers/resource-status-diff.ts | 2 +- .../src/extensions/amplify-helpers/resource-status.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/amplify-cli-core/src/index.ts b/packages/amplify-cli-core/src/index.ts index 28a826258f1..c32f3e19deb 100644 --- a/packages/amplify-cli-core/src/index.ts +++ b/packages/amplify-cli-core/src/index.ts @@ -1,5 +1,6 @@ import { ViewResourceTableParams, CLIParams } from './cliViewAPI'; import { ServiceSelection } from './serviceSelection'; +export { ViewResourceTableParams, CLIParams }; export * from './cfnUtilities'; export * from './cliContext'; diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index cc75eccb523..2ded9dc0a2c 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -1,4 +1,4 @@ -import { ViewResourceTableParams, CLIParams } from "amplify-cli-core/lib/cliViewAPI"; +import { ViewResourceTableParams, CLIParams } from "amplify-cli-core"; export const run = async context => { diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts index f38ff07ccad..0f232f955f3 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts @@ -125,7 +125,6 @@ export async function getResourceStatus( providerName?, filteredResources?, ): Promise { - const amplifyProjectInitStatus = getCloudInitStatus(); let { amplifyMeta, currentAmplifyMeta } = getAmplifyMeta(); let resourcesToBeCreated: any = getResourcesToBeCreated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); let resourcesToBeUpdated: any = await getResourcesToBeUpdated(amplifyMeta, currentAmplifyMeta, category, resourceName, filteredResources); diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index b15882458a1..7c7f4bb4143 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -15,7 +15,7 @@ const CategoryProviders = { interface StackMutationInfo { label : String; - consoleStyle : (string)=>string ; + consoleStyle : chalk.Chalk; icon : String; } //helper for summary styling diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index 0d216247355..ee1913a88ba 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -2,7 +2,7 @@ import { print } from './print'; import { CLOUD_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; -import { ViewResourceTableParams } from "amplify-cli-core/lib/cliViewAPI"; +import { ViewResourceTableParams } from "amplify-cli-core"; import { viewSummaryTable, viewEnvInfo, viewResourceDiffs } from './resource-status-view'; import { getMultiCategoryStatus, getResourceStatus, getHashForResourceDir } from './resource-status-data'; import chalk from 'chalk'; From 56ead1ba06736de1b54373481007f0adfceb5b27 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Mon, 12 Jul 2021 21:06:25 -0700 Subject: [PATCH 13/26] updated unit test coverage and fixed test errors --- packages/amplify-cli-core/src/cliViewAPI.ts | 21 +++ .../src/__tests__/commands/status.test.ts | 143 +++++++++++------- packages/amplify-cli/src/commands/status.ts | 11 +- .../amplify-helpers/resource-status-data.ts | 2 +- 4 files changed, 118 insertions(+), 59 deletions(-) diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index f8eb780947b..13820509e1e 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -17,20 +17,37 @@ export class ViewResourceTableParams { public get command() { return this._command; } + public set command( command : string) { + this._command = command; + } + public get verbose() { return this._verbose; } + public set verbose( tf: boolean ) { + this._verbose = tf; + } + public get help() { return this._help; } + public set help( isHelp: boolean ) { + this._help = isHelp; + } public get categoryList() { return this._categoryList; } + public set categoryList( categories: string[] | [] ){ + this._categoryList = categories + } public get filteredResourceList() { return this._filteredResourceList; } + public set filteredResourceList( resourceList : any ) { + this._filteredResourceList = resourceList ; + } getCategoryFromCLIOptions(cliOptions: object) { if (cliOptions) { @@ -78,6 +95,10 @@ ${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudfor `; } + public logErrorException( e : Error ){ + console.log(e.name , e.message); + } + public constructor(cliParams: CLIParams) { this._command = cliParams.cliCommand; this._verbose = cliParams.cliOptions?.verbose === true; diff --git a/packages/amplify-cli/src/__tests__/commands/status.test.ts b/packages/amplify-cli/src/__tests__/commands/status.test.ts index 9b7f057ca50..d22a798a354 100644 --- a/packages/amplify-cli/src/__tests__/commands/status.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/status.test.ts @@ -1,65 +1,98 @@ import { UnknownArgumentError } from 'amplify-cli-core'; - describe('amplify status: ', () => { - const mockExit = jest.fn(); - jest.mock('amplify-cli-core', () => ({ - exitOnNextTick: mockExit, - UnknownArgumentError: UnknownArgumentError, - })); - const { run } = require('../../commands/status'); - const runStatusCmd = run; + const { run } = require('../../commands/status'); + const runStatusCmd = run; + const statusPluginInfo = `${process.cwd()}/../amplify-console-hosting`; + const mockPath = './'; - it('status run method should exist', () => { - expect(runStatusCmd).toBeDefined(); - }); + it('status run method should exist', () => { + expect(runStatusCmd).toBeDefined(); + }); - it('status run method should call context.amplify.showStatusTable', async () => { - const mockContextNoCLArgs = { - amplify: { - showStatusTable: jest.fn(), - }, - parameters: { - array: [], + it('status run method should call context.amplify.showStatusTable', async () => { + const cliInput = { + command: 'status', + subCommands: [], + options: { + verbose: true, + }, + }; + + const mockContextNoCLArgs = { + amplify: { + showStatusTable: jest.fn(), + showHelpfulProviderLinks: jest.fn(), + getCategoryPluginInfo: jest.fn().mockReturnValue({ packageLocation: mockPath }), + }, + parameters: { + input: { + command: 'status', + subCommands: [], + options: { + verbose: true, }, - }; - runStatusCmd(mockContextNoCLArgs) - expect(mockContextNoCLArgs.amplify.showStatusTable).toBeCalled(); - }); + }, + }, + }; + runStatusCmd(mockContextNoCLArgs); + expect(mockContextNoCLArgs.amplify.showStatusTable).toBeCalled(); + }); + + it('status -v run method should call context.amplify.showStatusTable', async () => { + const mockContextWithVerboseOptionAndCLArgs = { + amplify: { + showStatusTable: jest.fn(), + showHelpfulProviderLinks: jest.fn(), + getCategoryPluginInfo: jest.fn().mockReturnValue({ packageLocation: statusPluginInfo }), + }, + input: { + command: 'status', + options: { + verbose: true, + }, + }, + }; + runStatusCmd(mockContextWithVerboseOptionAndCLArgs); + expect(mockContextWithVerboseOptionAndCLArgs.amplify.showStatusTable).toBeCalled(); + }); - it('status -v run method should call context.amplify.showStatusTable', async () => { - const mockContextWithVerboseOptionAndCLArgs = { - amplify: { - showStatusTable: jest.fn(), - }, - input :{ - command: "status", - options: { - verbose : true - } - } - }; - runStatusCmd(mockContextWithVerboseOptionAndCLArgs) - expect(mockContextWithVerboseOptionAndCLArgs.amplify.showStatusTable).toBeCalled(); - }); + it('status -v * run method should call context.amplify.showStatusTable', async () => { + const mockContextWithVerboseOptionWithCategoriesAndCLArgs = { + amplify: { + showStatusTable: jest.fn(), + showHelpfulProviderLinks: jest.fn(), + getCategoryPluginInfo: jest.fn().mockReturnValue({ packageLocation: statusPluginInfo }), + }, + input: { + command: 'status', + options: { + verbose: true, + api: true, + storage: true, + }, + }, + }; - it('status -v * run method should call context.amplify.showStatusTable', async () => { - const mockContextWithVerboseOptionWithCategoriesAndCLArgs = { - amplify: { - showStatusTable: jest.fn(), - }, - input :{ - command: "status", - options: { - verbose : true, - api : true, - storage : true - } - } - }; - runStatusCmd(mockContextWithVerboseOptionWithCategoriesAndCLArgs) - expect(mockContextWithVerboseOptionWithCategoriesAndCLArgs.amplify.showStatusTable).toBeCalled(); - }); + runStatusCmd(mockContextWithVerboseOptionWithCategoriesAndCLArgs); + expect(mockContextWithVerboseOptionWithCategoriesAndCLArgs.amplify.showStatusTable).toBeCalled(); + }); + it('status help run method should call ViewResourceTableParams.getStyledHelp', async () => { + const mockContextWithHelpSubcommandAndCLArgs = { + amplify: { + showStatusTable: jest.fn(), + showHelpfulProviderLinks: jest.fn(), + getCategoryPluginInfo: jest.fn().mockReturnValue({ packageLocation: statusPluginInfo }), + }, + input: { + command: 'status', + subCommands: ['help'], + }, + }; + runStatusCmd(mockContextWithHelpSubcommandAndCLArgs); + //TBD: to move ViewResourceTableParams into a separate file for mocking instance functions. + expect(mockContextWithHelpSubcommandAndCLArgs.amplify.showStatusTable.mock.calls.length).toBe(0); + }); -}) \ No newline at end of file +}); diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index 2ded9dc0a2c..7e216e1fa9b 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -5,13 +5,18 @@ export const run = async context => { const cliParams:CLIParams = { cliCommand : context?.input?.command, cliSubcommands: context?.input?.subCommands, cliOptions : context?.input?.options } + const view = new ViewResourceTableParams( cliParams ); if ( context?.input?.subCommands?.includes("help")){ console.log( view.getStyledHelp()) } else { - await context.amplify.showStatusTable( view ); - await context.amplify.showHelpfulProviderLinks(context); - await showAmplifyConsoleHostingStatus(context); + try { + await context.amplify.showStatusTable( view ); + await context.amplify.showHelpfulProviderLinks(context); + await showAmplifyConsoleHostingStatus(context); + } catch ( e ){ + view.logErrorException(e); + } } }; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts index 0f232f955f3..d99c5369469 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts @@ -8,7 +8,7 @@ import { hashElement, HashElementOptions } from 'folder-hash'; import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; import * as resourceStatus from './resource-status-diff'; import { IResourceDiffCollection } from './resource-status-diff'; -import { ViewResourceTableParams } from 'amplify-cli-core/src/cliViewAPI'; +import { ViewResourceTableParams } from 'amplify-cli-core'; //API: Filter resource status for the given categories From bc718d4d5cd088b19b9168b3db6bfee5e6c25082 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Thu, 15 Jul 2021 16:45:04 -0700 Subject: [PATCH 14/26] fix multi-env diffs for new resources --- packages/amplify-cli/src/commands/status.ts | 2 +- .../amplify-helpers/resource-status-diff.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index 7e216e1fa9b..2e485b372c0 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -23,7 +23,7 @@ export const run = async context => { async function showAmplifyConsoleHostingStatus( context) { const pluginInfo = context.amplify.getCategoryPluginInfo(context, 'hosting', 'amplifyhosting'); if (pluginInfo && pluginInfo.packageLocation) { - const { status } = require(pluginInfo.packageLocation); + const { status } = await import(pluginInfo.packageLocation); if (status) { await status(context); } diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 7c7f4bb4143..6bb23fb17d5 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -107,8 +107,9 @@ export class ResourceDiff { cloudBackendDir : string; localTemplate : Template; cloudTemplate : Template; + mutationInfo : StackMutationInfo; - constructor( category, resourceName, provider ){ + constructor( category, resourceName, provider, mutationInfo ){ this.localBackendDir = pathManager.getBackendDirPath(); this.cloudBackendDir = pathManager.getCurrentCloudBackendDirPath(); this.resourceName = resourceName; @@ -117,6 +118,7 @@ export class ResourceDiff { this.service = getResourceService(category, resourceName); this.localTemplate = {}; //requires file-access, hence loaded from async methods this.cloudTemplate = {}; //requires file-access, hence loaded from async methods + this.mutationInfo = mutationInfo; //Resource Path state const localResourceAbsolutePathFolder = path.normalize(path.join(this.localBackendDir, category, resourceName)); const cloudResourceAbsolutePathFolder = path.normalize(path.join(this.cloudBackendDir, category, resourceName)); @@ -148,6 +150,13 @@ export class ResourceDiff { this.localTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.localTemplatePath); this.cloudTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.cloudTemplatePath); + //Note!! :- special handling to support multi-env. Currently in multi-env, when new env is created, + //we do *Not* delete the cloud-backend folder. Hence this logic will always give no diff for new resources. + //TBD: REMOVE this once we delete cloud-backend folder for new envs + if ( this.mutationInfo.label == stackMutationType.CREATE.label ){ + this.cloudTemplate = {} ;//Dont use the parent env's template + } + //calculate diff of graphs. const diff : cfnDiff.TemplateDiff = cfnDiff.diffTemplate(this.cloudTemplate, this.localTemplate ); return diff; @@ -240,11 +249,11 @@ export interface ICategoryStatusCollection { //"CollateResourceDiffs" - Calculates the diffs for the list of resources provided. // note:- The mutationInfo may be used for styling in enhanced summary. -export async function CollateResourceDiffs( resources , _mutationInfo : StackMutationInfo /* create/update/delete */ ){ +export async function CollateResourceDiffs( resources , mutationInfo : StackMutationInfo /* create/update/delete */ ){ const provider = CategoryProviders.CLOUDFORMATION; let resourceDiffs : ResourceDiff[] = []; for await (let resource of resources) { - resourceDiffs.push( new ResourceDiff( resource.category, resource.resourceName, provider ) ); + resourceDiffs.push( new ResourceDiff( resource.category, resource.resourceName, provider, mutationInfo ) ); } return resourceDiffs; } From eea4be0e0ec2d36ed74f69998b09b7dabbdfd307 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 16 Jul 2021 12:45:54 -0700 Subject: [PATCH 15/26] 1. category filters for summary. 2. removed spacing between categories in detail-view. 3. updated help for summary. 4. added case normalization for options --- packages/amplify-cli-core/src/cliViewAPI.ts | 10 ++-- packages/amplify-cli/src/domain/constants.ts | 1 + .../amplify-helpers/resource-status-data.ts | 2 +- .../amplify-helpers/resource-status-view.ts | 3 -- packages/amplify-cli/src/index.ts | 52 +++++++++++++++---- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 13820509e1e..04b7c043ba7 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -79,14 +79,18 @@ ${this.styleHeader('NAME')} ${this.styleCommand('amplify status')} -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) ${this.styleHeader('SYNOPSIS')} -${this.styleCommand('amplify status')} [${this.styleCommand('-v')} [${this.styleOption('category ...')}] ] +${this.styleCommand('amplify status')} [${this.styleCommand('-v')}|${this.styleCommand('--verbose')}] [${this.styleOption('category ...')}] ${this.styleHeader('DESCRIPTION')} The amplify status command displays the difference between the deployed state and the local state of the application. The following options are available: -${this.styleNOOP('no options')} : (Summary mode) Displays the summary of local state vs deployed state of the application -${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudformation diff for all resources for the specificed category. +${this.styleCommand('[category ...]')} : (Summary mode) Displays the summary of local state vs deployed state of the application + usage: + ${this.stylePrompt('#>')} ${this.styleCommand('amplify status')} + ${this.stylePrompt('#>')} ${this.styleCommand('amplify status')} ${this.styleOption('api storage')} + +${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. If no category is provided, it shows the diff for all categories. usage: ${this.stylePrompt('#>')} ${this.styleCommand('amplify status -v')} diff --git a/packages/amplify-cli/src/domain/constants.ts b/packages/amplify-cli/src/domain/constants.ts index 9bbdbe5f336..0660e1b2cf8 100644 --- a/packages/amplify-cli/src/domain/constants.ts +++ b/packages/amplify-cli/src/domain/constants.ts @@ -3,6 +3,7 @@ export const constants = { HELP_SHORT: 'h', VERSION: 'version', VERSION_SHORT: 'v', + VERBOSE : 'verbose', YES: 'yes', YES_SHORT: 'y', PLUGIN_DEFAULT_COMMAND: 'PLUGIN_DEFAULT_COMMAND', diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts index d99c5369469..0db185b5098 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts @@ -486,7 +486,7 @@ export function getHashForResourceDir(dirPath, files?: string[]) { return hashElement(dirPath, options).then(result => result.hash); } -//helper: remove specificed resources from list of given resources +//helper: remove specified resources from list of given resources function filterResources(resources, filteredResources) { if (!filteredResources) { return resources; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts index 04d917e38d0..d901622634b 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts @@ -10,17 +10,14 @@ export async function viewResourceDiffs({ resourcesToBeUpdated, resourcesToBeDel //Print with UPDATE styling theme resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.UPDATE); } - print.info('\n'); for await (let resourceDiff of resourceDiffs.deletedDiff) { //Print with DELETE styling theme resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.DELETE); } - print.info('\n'); for await (let resourceDiff of resourceDiffs.createdDiff) { //Print with CREATE styling theme resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.CREATE); } - print.info('\n'); } //view: displays environment specific info diff --git a/packages/amplify-cli/src/index.ts b/packages/amplify-cli/src/index.ts index af4a98dbb65..3c38012a6e7 100644 --- a/packages/amplify-cli/src/index.ts +++ b/packages/amplify-cli/src/index.ts @@ -32,6 +32,7 @@ import { migrateTeamProviderInfo } from './utils/team-provider-migrate'; import { deleteOldVersion } from './utils/win-utils'; import { notify } from './version-notifier'; + // Adjust defaultMaxListeners to make sure Inquirer will not fail under Windows because of the multiple subscriptions // https://github.com/SBoudrias/Inquirer.js/issues/887 EventEmitter.defaultMaxListeners = 1000; @@ -64,17 +65,48 @@ process.on('unhandledRejection', function (error) { throw error; }); -function normalizeStatusCommandOptions( statusCommandOptions){ - let options = statusCommandOptions; +function convertKeysToLowerCase(obj : object){ + let newObj = {} + for( let key of Object.keys(obj) ){ + newObj[key.toLowerCase()] = obj[key]; + } + return newObj; +} + +function normalizeStatusCommandOptions( input : Input ){ + let options = (input.options)?input.options:{}; + const allowedVerboseIndicators = [constants.VERBOSE, 'v']; //Normalize 'amplify status -v' to verbose, since -v is interpreted as 'version' - if ( options && options.hasOwnProperty('v') ){ - options.verbose = true; - if ( typeof options['v'] === 'string'){ - options[ options['v'] ] = true ; - } - delete options['v'] + for( let verboseFlag of allowedVerboseIndicators ){ + if ( options.hasOwnProperty(verboseFlag) ){ + if ( typeof options[verboseFlag] === 'string' ){ + const pluginName = (options[verboseFlag] as string).toLowerCase(); + options[pluginName] = true ; + } + delete options[verboseFlag] + options['verbose'] = true; + } + } + //Merge plugins and subcommands as options (except help/verbose) + if ( input.plugin ){ + options[ input.plugin ] = true; + delete input.plugin + } + if ( input.subCommands ){ + const allowedSubCommands = [constants.HELP, constants.VERBOSE]; //list of subcommands supported in Status + let inputSubCommands:string[] = []; + input.subCommands.map( subCommand => { + //plugins are inferred as subcommands when positionally supplied + if( !allowedSubCommands.includes(subCommand) ) { + options[ subCommand.toLowerCase() ] = true; + } else { + inputSubCommands.push(subCommand); + } + }); + input.subCommands = inputSubCommands; } - return options; + input.options = convertKeysToLowerCase(options); //normalize keys to lower case + return input; } // entry from commandline @@ -92,7 +124,7 @@ export async function run() { //Normalize status command options if ( input.command == 'status'){ - input.options = normalizeStatusCommandOptions(input.options) + input = normalizeStatusCommandOptions(input) } // Initialize Banner messages. These messages are set on the server side From 5a5d2d6ed44f5ab3827671ccc6bc9a99473af0a1 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 16 Jul 2021 15:38:12 -0700 Subject: [PATCH 16/26] updated help to indicate summary filters --- .../amplify-cli/src/extensions/amplify-helpers/show-all-help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts b/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts index fa45b85afe5..3698b210190 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/show-all-help.ts @@ -35,7 +35,7 @@ export function showAllHelp(context) { description: "Executes amplify push, and then executes the project's start command to test run the client-side application locally.", }, { - name: 'status', + name: 'status [ ...]', description: 'Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete).', }, { From 82badbec860f1970df247d4de427c54fac7680a1 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore <83682223+sachscode@users.noreply.github.com> Date: Mon, 19 Jul 2021 10:43:03 -0700 Subject: [PATCH 17/26] Update Readme.md Added category filters for summary table --- Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index d82754b85fc..117981f458b 100644 --- a/Readme.md +++ b/Readme.md @@ -64,7 +64,7 @@ The Amplify CLI supports the commands shown in the following table. | amplify push [--no-gql-override] | Provisions cloud resources with the latest local developments. The 'no-gql-override' flag does not automatically compile your annotated GraphQL schema and will override your local AppSync resolvers and templates. | | amplify pull | Fetch upstream backend environment definition changes from the cloud and updates the local environment to match that definition. | | amplify publish | Runs `amplify push`, publishes a static assets to Amazon S3 and Amazon CloudFront (\*hosting category is required). | -| amplify status | Displays the state of local resources that haven't been pushed to the cloud (Create/Update/Delete). | +| amplify status [ ``...] | Displays the state of local resources that haven't been pushed to the cloud (Create/Update/Delete). | | amplify status -v [ ``...] | verbose mode - Shows the detailed verbose diff between local and deployed resources, including cloudformation-diff | | amplify serve | Runs `amplify push`, and then executes the project's start command to test run the client-side application. | | amplify delete | Deletes resources tied to the project. | From 0c1167cd008b3e9b66b51da9c7191ab5749d4c5d Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 23 Jul 2021 11:57:18 -0700 Subject: [PATCH 18/26] fixed unit tests and merge errors --- .../amplify-helpers/resource-status.test.ts | 13 ++++++++++--- .../extensions/amplify-helpers/resource-status.ts | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status.test.ts b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status.test.ts index a28cdd056c8..3f05acfb78b 100644 --- a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status.test.ts +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status.test.ts @@ -26,6 +26,13 @@ jest.mock('folder-hash', () => ({ jest.mock('chalk', () => ({ green: jest.fn().mockImplementation(input => input), + yellow: jest.fn().mockImplementation(input => input), + red: jest.fn().mockImplementation(input => input), + blue: jest.fn().mockImplementation(input => input), + gray: jest.fn().mockImplementation(input => input), + bgRgb: jest.fn().mockImplementation(input => input), + blueBright: jest.fn().mockImplementation(input => input), + greenBright: jest.fn().mockImplementation(input => input), })); jest.mock('../../../extensions/amplify-helpers/print', () => ({ @@ -719,10 +726,10 @@ describe('resource-status', () => { it('returns false and print empty markdown table format when no changed resources exists', async () => { const hasChanges = await showResourceTable(); expect(hasChanges).toBe(false); - expect(print.table).toBeCalledWith([['Category', 'Resource name', 'Operation', 'Provider plugin']], { format: 'markdown' }); + expect(print.table).toBeCalledWith([['Category', 'Resource name', 'Operation', 'Provider plugin']], { format: 'lean' }); }); - it('returns true and print resources as markdown table format when any changed resources exists', async () => { + it('returns true and print resources as lean table format when any changed resources exists', async () => { stateManagerMock.getMeta.mockReturnValue({ providers: { awscloudformation: {}, @@ -810,7 +817,7 @@ describe('resource-status', () => { ['Storage', 'testTable', 'Unlink', 'awscloudformation'], ['Function', 'lambda3', 'Delete', 'awscloudformation'], ], - { format: 'markdown' }, + { format: 'lean' }, ); }); }); diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts index f34f5b1761c..7dd98cde0cf 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -56,7 +56,7 @@ export async function showResourceTable(category?, resourceName?, filteredResour const amplifyProjectInitStatus = getCloudInitStatus(); if (amplifyProjectInitStatus === CLOUD_INITIALIZED) { - const { envName } = getEnvInfo().envName; + const { envName } = getEnvInfo(); print.info(''); print.info(`${chalk.green('Current Environment')}: ${envName}`); From e9c371e92f779fa1697ee15fa085278aca8d688a Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore <83682223+sachscode@users.noreply.github.com> Date: Fri, 23 Jul 2021 12:02:46 -0700 Subject: [PATCH 19/26] Update Readme.md fixed case --- Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 117981f458b..32c5cae7523 100644 --- a/Readme.md +++ b/Readme.md @@ -65,7 +65,7 @@ The Amplify CLI supports the commands shown in the following table. | amplify pull | Fetch upstream backend environment definition changes from the cloud and updates the local environment to match that definition. | | amplify publish | Runs `amplify push`, publishes a static assets to Amazon S3 and Amazon CloudFront (\*hosting category is required). | | amplify status [ ``...] | Displays the state of local resources that haven't been pushed to the cloud (Create/Update/Delete). | -| amplify status -v [ ``...] | verbose mode - Shows the detailed verbose diff between local and deployed resources, including cloudformation-diff | +| amplify status -v [ ``...] | Verbose mode - Shows the detailed verbose diff between local and deployed resources, including cloudformation-diff | | amplify serve | Runs `amplify push`, and then executes the project's start command to test run the client-side application. | | amplify delete | Deletes resources tied to the project. | | amplify help \| amplify `` help | Displays help for the core CLI. | From fb62416e2dd576c6d78cbf518e58b2c4ab9806f9 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore <83682223+sachscode@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:38:59 -0700 Subject: [PATCH 20/26] Update packages/amplify-cli-core/src/cliViewAPI.ts Co-authored-by: akshbhu <39866697+akshbhu@users.noreply.github.com> --- packages/amplify-cli-core/src/cliViewAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 04b7c043ba7..0b327b8298a 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; export interface CLIParams { cliCommand: string; cliSubcommands: string[] | undefined; - cliOptions: { [key: string]: any }; + cliOptions: Record; } //Resource Table filter and display params (params used for summary/display view of resource table) export class ViewResourceTableParams { From 7202d8dea86cd4313f7e9f4ea6bdd7091d3f01a7 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore <83682223+sachscode@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:40:06 -0700 Subject: [PATCH 21/26] Update packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts Co-authored-by: akshbhu <39866697+akshbhu@users.noreply.github.com> --- .../src/extensions/amplify-helpers/resource-status-view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts index d901622634b..4b3de54750c 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-view.ts @@ -6,7 +6,7 @@ import chalk from 'chalk'; //view: displays resource-diff (cloudformation-diff, input parameters (pending)) export async function viewResourceDiffs({ resourcesToBeUpdated, resourcesToBeDeleted, resourcesToBeCreated }) { const resourceDiffs = await getResourceDiffs(resourcesToBeUpdated, resourcesToBeDeleted, resourcesToBeCreated); - for await (let resourceDiff of resourceDiffs.updatedDiff) { + for await (const resourceDiff of resourceDiffs.updatedDiff) { //Print with UPDATE styling theme resourceDiff.printResourceDetailStatus(resourceStatus.stackMutationType.UPDATE); } From 318e13d33cc7c9dbb6ead534f7bc1cc05012d2c8 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Fri, 23 Jul 2021 23:50:03 -0700 Subject: [PATCH 22/26] addressed CR comments --- packages/amplify-cli-core/src/cliViewAPI.ts | 6 +- packages/amplify-cli-core/src/index.ts | 5 +- packages/amplify-cli/src/commands/status.ts | 8 +- .../amplify-helpers/resource-status-data.ts | 122 ++++++++---------- .../amplify-helpers/resource-status-diff.ts | 11 +- .../show-helpful-provider-links.ts | 3 +- 6 files changed, 74 insertions(+), 81 deletions(-) diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 0b327b8298a..935071f41f5 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -1,6 +1,8 @@ //Use this file to store all types used between the CLI commands and the view/display functions // CLI=>(command-handler)==[CLI-View-API]=>(ux-handler/report-handler)=>output-stream import chalk from 'chalk'; +import { isContext } from 'vm'; +import { $TSAny, $TSContext } from '.'; export interface CLIParams { cliCommand: string; cliSubcommands: string[] | undefined; @@ -99,8 +101,8 @@ ${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudfor `; } - public logErrorException( e : Error ){ - console.log(e.name , e.message); + public logErrorException( e : Error , context : $TSContext ){ + context.print.error(`Name: ${e.name} : Message: ${e.message}`); } public constructor(cliParams: CLIParams) { diff --git a/packages/amplify-cli-core/src/index.ts b/packages/amplify-cli-core/src/index.ts index c32f3e19deb..a32107b9028 100644 --- a/packages/amplify-cli-core/src/index.ts +++ b/packages/amplify-cli-core/src/index.ts @@ -1,6 +1,5 @@ -import { ViewResourceTableParams, CLIParams } from './cliViewAPI'; +import { ViewResourceTableParams } from './cliViewAPI'; import { ServiceSelection } from './serviceSelection'; -export { ViewResourceTableParams, CLIParams }; export * from './cfnUtilities'; export * from './cliContext'; @@ -218,7 +217,7 @@ interface AmplifyToolkit { sharedQuestions: () => $TSAny; showAllHelp: () => $TSAny; showHelp: (header: string, commands: { name: string; description: string }[]) => $TSAny; - showHelpfulProviderLinks: () => $TSAny; + showHelpfulProviderLinks: (context : $TSContext) => $TSAny; showResourceTable: () => $TSAny; showStatusTable:( resourceTableParams : ViewResourceTableParams )=> $TSAny; //Enhanced Status with CFN-Diff serviceSelectionPrompt: ( diff --git a/packages/amplify-cli/src/commands/status.ts b/packages/amplify-cli/src/commands/status.ts index 2e485b372c0..3e11576346a 100644 --- a/packages/amplify-cli/src/commands/status.ts +++ b/packages/amplify-cli/src/commands/status.ts @@ -1,21 +1,21 @@ -import { ViewResourceTableParams, CLIParams } from "amplify-cli-core"; +import { ViewResourceTableParams, CLIParams, $TSContext } from "amplify-cli-core"; -export const run = async context => { +export const run = async (context : $TSContext) => { const cliParams:CLIParams = { cliCommand : context?.input?.command, cliSubcommands: context?.input?.subCommands, cliOptions : context?.input?.options } const view = new ViewResourceTableParams( cliParams ); if ( context?.input?.subCommands?.includes("help")){ - console.log( view.getStyledHelp()) + context.print.info( view.getStyledHelp() ); } else { try { await context.amplify.showStatusTable( view ); await context.amplify.showHelpfulProviderLinks(context); await showAmplifyConsoleHostingStatus(context); } catch ( e ){ - view.logErrorException(e); + view.logErrorException(e, context); } } }; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts index 0db185b5098..e0645441fe8 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-data.ts @@ -3,13 +3,11 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import { ServiceName as FunctionServiceName, hashLayerResource } from 'amplify-category-function'; import { removeGetUserEndpoints } from '../amplify-helpers/remove-pinpoint-policy'; -import { pathManager, stateManager, NotInitializedError } from 'amplify-cli-core'; +import { pathManager, stateManager, NotInitializedError, ViewResourceTableParams } from 'amplify-cli-core'; import { hashElement, HashElementOptions } from 'folder-hash'; import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status'; import * as resourceStatus from './resource-status-diff'; -import { IResourceDiffCollection } from './resource-status-diff'; -import { ViewResourceTableParams } from 'amplify-cli-core'; - +import { IResourceDiffCollection, capitalize } from './resource-status-diff'; //API: Filter resource status for the given categories export async function getMultiCategoryStatus(inputs: ViewResourceTableParams | undefined) { @@ -35,6 +33,43 @@ export async function getResourceDiffs(resourcesToBeUpdated, resourcesToBeDelete return result; } +function resourceToTableRow( resource, operation ){ + return [ + capitalize(resource.category), + resource.resourceName, + operation /*syncOperationLabel*/, + resource.providerPlugin, + ] +} + +const ResourceOperationLabel = { + Create : 'Create', + Update : 'Update', + Delete : 'Delete', + Import : 'Import', + Unlink : 'Unlink', + NoOp : 'No Change', +} + +const TableColumnLabels = { + Category: 'Category', + ResourceName: 'Resource name', + Operation: 'Operation', + ProviderPlugin: 'Provider plugin' +} + +function getLabelForResourceSyncOperation( syncOperationType : string ) { + switch (syncOperationType) { + case 'import': + return ResourceOperationLabel.Import; + case 'unlink': + return ResourceOperationLabel.Unlink; + default: + // including refresh + return ResourceOperationLabel.NoOp; + } +} + export function getSummaryTableData({ resourcesToBeUpdated, resourcesToBeDeleted, @@ -49,72 +84,30 @@ export function getSummaryTableData({ ); noChangeResources = noChangeResources.filter(resource => resource.category !== 'providers'); - const createOperationLabel = 'Create'; - const updateOperationLabel = 'Update'; - const deleteOperationLabel = 'Delete'; - const importOperationLabel = 'Import'; - const unlinkOperationLabel = 'Unlink'; - const noOperationLabel = 'No Change'; - const tableOptions = [['Category', 'Resource name', 'Operation', 'Provider plugin']]; - - for (let i = 0; i < resourcesToBeCreated.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeCreated[i].category), - resourcesToBeCreated[i].resourceName, - createOperationLabel, - resourcesToBeCreated[i].providerPlugin, - ]); - } + const tableOptions = [[TableColumnLabels.Category, + TableColumnLabels.ResourceName, + TableColumnLabels.Operation, + TableColumnLabels.ProviderPlugin]]; - for (let i = 0; i < resourcesToBeUpdated.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeUpdated[i].category), - resourcesToBeUpdated[i].resourceName, - updateOperationLabel, - resourcesToBeUpdated[i].providerPlugin, - ]); + for ( const resource of resourcesToBeCreated ) { + tableOptions.push( resourceToTableRow(resource, ResourceOperationLabel.Create) ); } - for (let i = 0; i < resourcesToBeSynced.length; ++i) { - let operation; - - switch (resourcesToBeSynced[i].sync) { - case 'import': - operation = importOperationLabel; - break; - case 'unlink': - operation = unlinkOperationLabel; - break; - default: - // including refresh - operation = noOperationLabel; - break; - } + for (const resource of resourcesToBeUpdated) { + tableOptions.push( resourceToTableRow(resource, ResourceOperationLabel.Update) ) + } - tableOptions.push([ - capitalize(resourcesToBeSynced[i].category), - resourcesToBeSynced[i].resourceName, - operation /*syncOperationLabel*/, - resourcesToBeSynced[i].providerPlugin, - ]); + for (const resource of resourcesToBeSynced) { + const operation = getLabelForResourceSyncOperation(resource.sync); + tableOptions.push( resourceToTableRow( resource, operation /*syncOperationLabel*/ ) ) } - for (let i = 0; i < resourcesToBeDeleted.length; ++i) { - tableOptions.push([ - capitalize(resourcesToBeDeleted[i].category), - resourcesToBeDeleted[i].resourceName, - deleteOperationLabel, - resourcesToBeDeleted[i].providerPlugin, - ]); + for (const resource of resourcesToBeDeleted) { + tableOptions.push(resourceToTableRow(resource, ResourceOperationLabel.Delete)); } - for (let i = 0; i < noChangeResources.length; ++i) { - tableOptions.push([ - capitalize(noChangeResources[i].category), - noChangeResources[i].resourceName, - noOperationLabel, - noChangeResources[i].providerPlugin, - ]); + for (const resource of noChangeResources ) { + tableOptions.push(resourceToTableRow( resource, ResourceOperationLabel.NoOp)); } return tableOptions; } @@ -530,7 +523,4 @@ async function asyncForEach(array, callback) { await callback(array[index], index, array); } } -//TODO: replace with some library function -function capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); -} + diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index 6bb23fb17d5..f12651636c3 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -63,14 +63,15 @@ export const stackMutationType : StackMutationType = { icon : `[ ]` } } -//Console text styling for resource details section -const resourceDetailSectionStyle = chalk.bgRgb(15, 100, 204) //helper to capitalize string -function capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +export function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); } +//Console text styling for resource details section +const resourceDetailSectionStyle = chalk.bgRgb(15, 100, 204) + //IResourcePaths: Interface for (build/prebuild) paths to local and cloud CFN files interface IResourcePaths { localPreBuildCfnFile : string; @@ -252,7 +253,7 @@ export interface ICategoryStatusCollection { export async function CollateResourceDiffs( resources , mutationInfo : StackMutationInfo /* create/update/delete */ ){ const provider = CategoryProviders.CLOUDFORMATION; let resourceDiffs : ResourceDiff[] = []; - for await (let resource of resources) { + for await (const resource of resources) { resourceDiffs.push( new ResourceDiff( resource.category, resource.resourceName, provider, mutationInfo ) ); } return resourceDiffs; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/show-helpful-provider-links.ts b/packages/amplify-cli/src/extensions/amplify-helpers/show-helpful-provider-links.ts index 1582c42906c..7c4d5d2f511 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/show-helpful-provider-links.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/show-helpful-provider-links.ts @@ -1,8 +1,9 @@ import { getProjectConfig } from './get-project-config'; import { getResourceStatus } from './resource-status'; import { getProviderPlugins } from './get-provider-plugins'; +import { $TSContext } from 'amplify-cli-core'; -export async function showHelpfulProviderLinks(context) { +export async function showHelpfulProviderLinks(context: $TSContext) { const { providers } = getProjectConfig(); const providerPlugins = getProviderPlugins(context); const providerPromises: (() => Promise)[] = []; From 92555b621b9737e89c04e51426fbb950344f2452 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Sat, 24 Jul 2021 12:27:20 -0700 Subject: [PATCH 23/26] lgtm:fix:removed unused variable --- packages/amplify-cli-core/src/cliViewAPI.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 935071f41f5..d7ce9dd2d6d 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -1,7 +1,6 @@ //Use this file to store all types used between the CLI commands and the view/display functions // CLI=>(command-handler)==[CLI-View-API]=>(ux-handler/report-handler)=>output-stream import chalk from 'chalk'; -import { isContext } from 'vm'; import { $TSAny, $TSContext } from '.'; export interface CLIParams { cliCommand: string; From d78e7abe26660a4bf6c86f479f3c4d236c284b2b Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Mon, 26 Jul 2021 19:36:38 -0700 Subject: [PATCH 24/26] unit-tests for cliViewAPI --- .../__snapshots__/cliViewAPI.test.ts.snap | 27 ++++++++ .../src/__tests__/cliViewAPI.test.ts | 61 +++++++++++++++++++ packages/amplify-cli-core/src/cliViewAPI.ts | 26 -------- 3 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap create mode 100644 packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts diff --git a/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap b/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap new file mode 100644 index 00000000000..4074e9304ee --- /dev/null +++ b/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CLI View tests Status Help CLI should correctly return styled help message 1`] = ` +" +NAME +amplify status -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) + +SYNOPSIS +amplify status [-v|--verbose] [category ...] + +DESCRIPTION +The amplify status command displays the difference between the deployed state and the local state of the application. +The following options are available: + +[category ...] : (Summary mode) Displays the summary of local state vs deployed state of the application + usage: + #> amplify status + #> amplify status api storage + +-v [category ...] : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. + If no category is provided, it shows the diff for all categories. + usage: + #> amplify status -v + #> amplify status -v api storage + + " +`; diff --git a/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts b/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts new file mode 100644 index 00000000000..cad1bb9ded9 --- /dev/null +++ b/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts @@ -0,0 +1,61 @@ +import { CLIParams, ViewResourceTableParams } from '../cliViewAPI'; +describe('CLI View tests', () => { + test('Verbose mode CLI status with category list should correctly initialize ViewResourceTableParams [Non-Help]', () => { + const cliParams : CLIParams = { + cliCommand: 'status', + cliSubcommands: undefined, + cliOptions: { + storage: true, + api: true, + verbose: true, + yes: false + } + } + const view = new ViewResourceTableParams(cliParams); + expect( view.command ).toBe("status"); + expect( view.categoryList).toStrictEqual(['storage', 'api']); + expect( view.help ).toBe(false); + expect( view.verbose ).toBe(true); + }); + + test('Status Help CLI should correctly return styled help message', () => { + const cliParams : CLIParams = { + cliCommand: 'status', + cliSubcommands: [ 'help' ], + cliOptions: { yes: false } + }; + + const view = new ViewResourceTableParams(cliParams); + expect( view.command ).toBe("status"); + expect( view.categoryList).toStrictEqual([]); + expect( view.help ).toBe(true); + expect( view.verbose ).toBe(false); + expect(view.getStyledHelp()).toMatchSnapshot(); + }); + + test('Status Command should print error message to the screen', () => { + const cliParams : CLIParams = { + cliCommand: 'status', + cliSubcommands: [ 'help' ], + cliOptions: { yes: false } + }; + const view = new ViewResourceTableParams(cliParams); + const errorMockFn = jest.fn(); + + const context: any = { + print : { + error: errorMockFn + } + }; + const errorMessage = "Something bad happened" + try { + throw new Error(errorMessage); + } + catch(e) { + view.logErrorException(e, context); + expect(errorMockFn).toBeCalledTimes(1); + } + + }); + +} ); \ No newline at end of file diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index d7ce9dd2d6d..365d30b8b74 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -18,38 +18,15 @@ export class ViewResourceTableParams { public get command() { return this._command; } - public set command( command : string) { - this._command = command; - } - public get verbose() { return this._verbose; } - public set verbose( tf: boolean ) { - this._verbose = tf; - } - public get help() { return this._help; } - public set help( isHelp: boolean ) { - this._help = isHelp; - } - public get categoryList() { return this._categoryList; } - public set categoryList( categories: string[] | [] ){ - this._categoryList = categories - } - - public get filteredResourceList() { - return this._filteredResourceList; - } - public set filteredResourceList( resourceList : any ) { - this._filteredResourceList = resourceList ; - } - getCategoryFromCLIOptions(cliOptions: object) { if (cliOptions) { return Object.keys(cliOptions) @@ -71,9 +48,6 @@ export class ViewResourceTableParams { stylePrompt(str: string) { return chalk.bold(chalk.yellowBright(str)); } - styleNOOP(str: string) { - return chalk.italic(chalk.grey(str)); - } public getStyledHelp() { return ` ${this.styleHeader('NAME')} From 8e1216d41be9a68480e50e7d96526fdb2226ed3d Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Tue, 27 Jul 2021 14:02:34 -0700 Subject: [PATCH 25/26] removed styling from help test --- package.json | 3 ++- .../__snapshots__/cliViewAPI.test.ts.snap | 22 +++++++++---------- .../src/__tests__/cliViewAPI.test.ts | 6 ++++- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 6becb11adff..9b49b7474b0 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "author": "Amazon Web Services", "license": "Apache-2.0", "dependencies": { - "lerna": "^3.16.4" + "lerna": "^3.16.4", + "strip-ansi": "^6.0.0" }, "workspaces": { "packages": [ diff --git a/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap b/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap index 4074e9304ee..0765b9ff796 100644 --- a/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap +++ b/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap @@ -2,26 +2,26 @@ exports[`CLI View tests Status Help CLI should correctly return styled help message 1`] = ` " -NAME -amplify status -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) +NAME +amplify status -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) -SYNOPSIS -amplify status [-v|--verbose] [category ...] +SYNOPSIS +amplify status [-v|--verbose] [category ...] -DESCRIPTION +DESCRIPTION The amplify status command displays the difference between the deployed state and the local state of the application. The following options are available: -[category ...] : (Summary mode) Displays the summary of local state vs deployed state of the application +[category ...] : (Summary mode) Displays the summary of local state vs deployed state of the application usage: - #> amplify status - #> amplify status api storage + #> amplify status + #> amplify status api storage --v [category ...] : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. +-v [category ...] : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. If no category is provided, it shows the diff for all categories. usage: - #> amplify status -v - #> amplify status -v api storage + #> amplify status -v + #> amplify status -v api storage " `; diff --git a/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts b/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts index cad1bb9ded9..adb641e1ecb 100644 --- a/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts +++ b/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts @@ -1,4 +1,7 @@ import { CLIParams, ViewResourceTableParams } from '../cliViewAPI'; +import chalk from 'chalk'; +import stripAnsi from 'strip-ansi'; + describe('CLI View tests', () => { test('Verbose mode CLI status with category list should correctly initialize ViewResourceTableParams [Non-Help]', () => { const cliParams : CLIParams = { @@ -30,7 +33,8 @@ describe('CLI View tests', () => { expect( view.categoryList).toStrictEqual([]); expect( view.help ).toBe(true); expect( view.verbose ).toBe(false); - expect(view.getStyledHelp()).toMatchSnapshot(); + const styledHelp = stripAnsi(chalk.reset(view.getStyledHelp())); + expect(styledHelp).toMatchSnapshot(); }); test('Status Command should print error message to the screen', () => { From b984488e9ebfbbe567b05cc9787a440615a4d381 Mon Sep 17 00:00:00 2001 From: Sachin Panemangalore Date: Mon, 2 Aug 2021 13:13:24 -0700 Subject: [PATCH 26/26] unit-test for detailed cloudformation-diff for one resource --- .../resource-status-diff.test.ts.snap | 1189 +++++++++++++++++ .../resource-status-diff.test.ts | 168 +++ .../mockCurrentCloud/CustomResources.json | 61 + .../testData/mockCurrentCloud/Todo.json | 854 ++++++++++++ .../cloudformation-template.json | 419 ++++++ .../testData/mockCurrentCloud/parameters.json | 8 + .../mockLocalCloud/CustomResources.json | 61 + .../testData/mockLocalCloud/Todo.json | 1043 +++++++++++++++ .../testData/mockLocalCloud/Todo1.json | 1043 +++++++++++++++ .../testData/mockLocalCloud/Todo2.json | 1043 +++++++++++++++ .../cloudformation-template.json | 643 +++++++++ .../testData/mockLocalCloud/parameters.json | 14 + .../amplify-helpers/resource-status-diff.ts | 15 +- 13 files changed, 6553 insertions(+), 8 deletions(-) create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/__snapshots__/resource-status-diff.test.ts.snap create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status-diff.test.ts create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/CustomResources.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/Todo.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/cloudformation-template.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/parameters.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/CustomResources.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo1.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo2.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/cloudformation-template.json create mode 100644 packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/parameters.json diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/__snapshots__/resource-status-diff.test.ts.snap b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/__snapshots__/resource-status-diff.test.ts.snap new file mode 100644 index 00000000000..51c969a8772 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/__snapshots__/resource-status-diff.test.ts.snap @@ -0,0 +1,1189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`resource-status-diff helpers should show the diff between local and remote cloudformation 1`] = ` +TemplateDiff { + "conditions": DifferenceCollection { + "diffs": Object {}, + }, + "iamChanges": IamChanges { + "managedPolicies": DiffableCollection { + "additions": Array [], + "newElements": Array [], + "oldElements": Array [], + "removals": Array [], + }, + "statements": DiffableCollection { + "additions": Array [], + "newElements": Array [], + "oldElements": Array [], + "removals": Array [], + }, + }, + "mappings": DifferenceCollection { + "diffs": Object {}, + }, + "metadata": DifferenceCollection { + "diffs": Object {}, + }, + "outputs": DifferenceCollection { + "diffs": Object {}, + }, + "parameters": DifferenceCollection { + "diffs": Object {}, + }, + "resources": DifferenceCollection { + "diffs": Object { + "CustomResourcesjson": ResourceDifference { + "isAddition": false, + "isRemoval": false, + "newValue": Object { + "DependsOn": Array [ + "GraphQLAPI", + "GraphQLSchema", + "Todo", + "Todo1", + "Todo2", + ], + "Properties": Object { + "Parameters": Object { + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "TemplateURL": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "CustomResources.json", + ], + ], + }, + }, + "Type": "AWS::CloudFormation::Stack", + }, + "oldValue": Object { + "DependsOn": Array [ + "GraphQLAPI", + "GraphQLSchema", + "Todo", + ], + "Properties": Object { + "Parameters": Object { + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "TemplateURL": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "CustomResources.json", + ], + ], + }, + }, + "Type": "AWS::CloudFormation::Stack", + }, + "otherDiffs": Object { + "DependsOn": Difference { + "isDifferent": true, + "newValue": Array [ + "GraphQLAPI", + "GraphQLSchema", + "Todo", + "Todo1", + "Todo2", + ], + "oldValue": Array [ + "GraphQLAPI", + "GraphQLSchema", + "Todo", + ], + }, + "Type": Difference { + "isDifferent": false, + "newValue": "AWS::CloudFormation::Stack", + "oldValue": "AWS::CloudFormation::Stack", + }, + }, + "propertyDiffs": Object { + "Parameters": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "oldValue": Object { + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + }, + "TemplateURL": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "CustomResources.json", + ], + ], + }, + "oldValue": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "CustomResources.json", + ], + ], + }, + }, + }, + "resourceTypes": Object { + "newType": "AWS::CloudFormation::Stack", + "oldType": "AWS::CloudFormation::Stack", + }, + }, + "DataStore": ResourceDifference { + "isAddition": true, + "isRemoval": false, + "newValue": Object { + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "ds_pk", + "AttributeType": "S", + }, + Object { + "AttributeName": "ds_sk", + "AttributeType": "S", + }, + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": Array [ + Object { + "AttributeName": "ds_pk", + "KeyType": "HASH", + }, + Object { + "AttributeName": "ds_sk", + "KeyType": "RANGE", + }, + ], + "TableName": Object { + "Fn::If": Array [ + "HasEnvironmentParameter", + Object { + "Fn::Join": Array [ + "-", + Array [ + "AmplifyDataStore", + Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + Object { + "Ref": "env", + }, + ], + ], + }, + Object { + "Fn::Join": Array [ + "-", + Array [ + "AmplifyDataStore", + Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + ], + ], + }, + ], + }, + "TimeToLiveSpecification": Object { + "AttributeName": "_ttl", + "Enabled": true, + }, + }, + "Type": "AWS::DynamoDB::Table", + }, + "oldValue": undefined, + "otherDiffs": Object {}, + "propertyDiffs": Object {}, + "resourceTypes": Object { + "newType": "AWS::DynamoDB::Table", + "oldType": undefined, + }, + }, + "GraphQLAPI": ResourceDifference { + "isAddition": false, + "isRemoval": false, + "newValue": Object { + "Properties": Object { + "AuthenticationType": "AMAZON_COGNITO_USER_POOLS", + "Name": Object { + "Fn::If": Array [ + "HasEnvironmentParameter", + Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Ref": "AppSyncApiName", + }, + Object { + "Ref": "env", + }, + ], + ], + }, + Object { + "Ref": "AppSyncApiName", + }, + ], + }, + "UserPoolConfig": Object { + "AwsRegion": Object { + "Ref": "AWS::Region", + }, + "DefaultAction": "ALLOW", + "UserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + }, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, + "oldValue": Object { + "Properties": Object { + "AuthenticationType": "API_KEY", + "Name": Object { + "Fn::If": Array [ + "HasEnvironmentParameter", + Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Ref": "AppSyncApiName", + }, + Object { + "Ref": "env", + }, + ], + ], + }, + Object { + "Ref": "AppSyncApiName", + }, + ], + }, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, + "otherDiffs": Object { + "Type": Difference { + "isDifferent": false, + "newValue": "AWS::AppSync::GraphQLApi", + "oldValue": "AWS::AppSync::GraphQLApi", + }, + }, + "propertyDiffs": Object { + "AuthenticationType": PropertyDifference { + "changeImpact": "WILL_UPDATE", + "isDifferent": true, + "newValue": "AMAZON_COGNITO_USER_POOLS", + "oldValue": "API_KEY", + }, + "Name": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "Fn::If": Array [ + "HasEnvironmentParameter", + Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Ref": "AppSyncApiName", + }, + Object { + "Ref": "env", + }, + ], + ], + }, + Object { + "Ref": "AppSyncApiName", + }, + ], + }, + "oldValue": Object { + "Fn::If": Array [ + "HasEnvironmentParameter", + Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Ref": "AppSyncApiName", + }, + Object { + "Ref": "env", + }, + ], + ], + }, + Object { + "Ref": "AppSyncApiName", + }, + ], + }, + }, + "UserPoolConfig": PropertyDifference { + "changeImpact": "WILL_UPDATE", + "isDifferent": true, + "newValue": Object { + "AwsRegion": Object { + "Ref": "AWS::Region", + }, + "DefaultAction": "ALLOW", + "UserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + }, + "oldValue": undefined, + }, + }, + "resourceTypes": Object { + "newType": "AWS::AppSync::GraphQLApi", + "oldType": "AWS::AppSync::GraphQLApi", + }, + }, + "GraphQLAPIKey": ResourceDifference { + "isAddition": false, + "isRemoval": false, + "newValue": Object { + "Condition": "ShouldCreateAPIKey", + "Properties": Object { + "ApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "Expires": Object { + "Fn::If": Array [ + "APIKeyExpirationEpochIsPositive", + Object { + "Ref": "APIKeyExpirationEpoch", + }, + 1625095600, + ], + }, + }, + "Type": "AWS::AppSync::ApiKey", + }, + "oldValue": Object { + "Condition": "ShouldCreateAPIKey", + "Properties": Object { + "ApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "Description": "demo", + "Expires": Object { + "Fn::If": Array [ + "APIKeyExpirationEpochIsPositive", + Object { + "Ref": "APIKeyExpirationEpoch", + }, + 1624238916, + ], + }, + }, + "Type": "AWS::AppSync::ApiKey", + }, + "otherDiffs": Object { + "Condition": Difference { + "isDifferent": false, + "newValue": "ShouldCreateAPIKey", + "oldValue": "ShouldCreateAPIKey", + }, + "Type": Difference { + "isDifferent": false, + "newValue": "AWS::AppSync::ApiKey", + "oldValue": "AWS::AppSync::ApiKey", + }, + }, + "propertyDiffs": Object { + "ApiId": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "oldValue": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + }, + "Description": PropertyDifference { + "changeImpact": "WILL_UPDATE", + "isDifferent": true, + "newValue": undefined, + "oldValue": "demo", + }, + "Expires": PropertyDifference { + "changeImpact": "WILL_UPDATE", + "isDifferent": true, + "newValue": Object { + "Fn::If": Array [ + "APIKeyExpirationEpochIsPositive", + Object { + "Ref": "APIKeyExpirationEpoch", + }, + 1625095600, + ], + }, + "oldValue": Object { + "Fn::If": Array [ + "APIKeyExpirationEpochIsPositive", + Object { + "Ref": "APIKeyExpirationEpoch", + }, + 1624238916, + ], + }, + }, + }, + "resourceTypes": Object { + "newType": "AWS::AppSync::ApiKey", + "oldType": "AWS::AppSync::ApiKey", + }, + }, + "GraphQLSchema": ResourceDifference { + "isAddition": false, + "isRemoval": false, + "newValue": Object { + "Properties": Object { + "ApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "DefinitionS3Location": Object { + "Fn::Sub": Array [ + "s3://\${S3DeploymentBucket}/\${S3DeploymentRootKey}/schema.graphql", + Object { + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + }, + ], + }, + }, + "Type": "AWS::AppSync::GraphQLSchema", + }, + "oldValue": Object { + "Properties": Object { + "ApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "DefinitionS3Location": Object { + "Fn::Sub": Array [ + "s3://\${S3DeploymentBucket}/\${S3DeploymentRootKey}/schema.graphql", + Object { + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + }, + ], + }, + }, + "Type": "AWS::AppSync::GraphQLSchema", + }, + "otherDiffs": Object { + "Type": Difference { + "isDifferent": false, + "newValue": "AWS::AppSync::GraphQLSchema", + "oldValue": "AWS::AppSync::GraphQLSchema", + }, + }, + "propertyDiffs": Object { + "ApiId": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "oldValue": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + }, + "DefinitionS3Location": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "Fn::Sub": Array [ + "s3://\${S3DeploymentBucket}/\${S3DeploymentRootKey}/schema.graphql", + Object { + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + }, + ], + }, + "oldValue": Object { + "Fn::Sub": Array [ + "s3://\${S3DeploymentBucket}/\${S3DeploymentRootKey}/schema.graphql", + Object { + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + }, + ], + }, + }, + }, + "resourceTypes": Object { + "newType": "AWS::AppSync::GraphQLSchema", + "oldType": "AWS::AppSync::GraphQLSchema", + }, + }, + "Todo": ResourceDifference { + "isAddition": false, + "isRemoval": false, + "newValue": Object { + "DependsOn": Array [ + "GraphQLSchema", + ], + "Properties": Object { + "Parameters": Object { + "APIKeyExpirationEpoch": Object { + "Ref": "APIKeyExpirationEpoch", + }, + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "AuthCognitoUserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + "CreateAPIKey": Object { + "Ref": "CreateAPIKey", + }, + "DynamoDBBillingMode": Object { + "Ref": "DynamoDBBillingMode", + }, + "DynamoDBEnablePointInTimeRecovery": Object { + "Ref": "DynamoDBEnablePointInTimeRecovery", + }, + "DynamoDBEnableServerSideEncryption": Object { + "Ref": "DynamoDBEnableServerSideEncryption", + }, + "DynamoDBModelTableReadIOPS": Object { + "Ref": "DynamoDBModelTableReadIOPS", + }, + "DynamoDBModelTableWriteIOPS": Object { + "Ref": "DynamoDBModelTableWriteIOPS", + }, + "GetAttGraphQLAPIApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "TemplateURL": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "Todo.json", + ], + ], + }, + }, + "Type": "AWS::CloudFormation::Stack", + }, + "oldValue": Object { + "DependsOn": Array [ + "GraphQLSchema", + ], + "Properties": Object { + "Parameters": Object { + "APIKeyExpirationEpoch": Object { + "Ref": "APIKeyExpirationEpoch", + }, + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "AuthCognitoUserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + "CreateAPIKey": Object { + "Ref": "CreateAPIKey", + }, + "DynamoDBBillingMode": Object { + "Ref": "DynamoDBBillingMode", + }, + "DynamoDBEnablePointInTimeRecovery": Object { + "Ref": "DynamoDBEnablePointInTimeRecovery", + }, + "DynamoDBEnableServerSideEncryption": Object { + "Ref": "DynamoDBEnableServerSideEncryption", + }, + "DynamoDBModelTableReadIOPS": Object { + "Ref": "DynamoDBModelTableReadIOPS", + }, + "DynamoDBModelTableWriteIOPS": Object { + "Ref": "DynamoDBModelTableWriteIOPS", + }, + "GetAttGraphQLAPIApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "TemplateURL": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "Todo.json", + ], + ], + }, + }, + "Type": "AWS::CloudFormation::Stack", + }, + "otherDiffs": Object { + "DependsOn": Difference { + "isDifferent": false, + "newValue": Array [ + "GraphQLSchema", + ], + "oldValue": Array [ + "GraphQLSchema", + ], + }, + "Type": Difference { + "isDifferent": false, + "newValue": "AWS::CloudFormation::Stack", + "oldValue": "AWS::CloudFormation::Stack", + }, + }, + "propertyDiffs": Object { + "Parameters": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "APIKeyExpirationEpoch": Object { + "Ref": "APIKeyExpirationEpoch", + }, + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "AuthCognitoUserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + "CreateAPIKey": Object { + "Ref": "CreateAPIKey", + }, + "DynamoDBBillingMode": Object { + "Ref": "DynamoDBBillingMode", + }, + "DynamoDBEnablePointInTimeRecovery": Object { + "Ref": "DynamoDBEnablePointInTimeRecovery", + }, + "DynamoDBEnableServerSideEncryption": Object { + "Ref": "DynamoDBEnableServerSideEncryption", + }, + "DynamoDBModelTableReadIOPS": Object { + "Ref": "DynamoDBModelTableReadIOPS", + }, + "DynamoDBModelTableWriteIOPS": Object { + "Ref": "DynamoDBModelTableWriteIOPS", + }, + "GetAttGraphQLAPIApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "oldValue": Object { + "APIKeyExpirationEpoch": Object { + "Ref": "APIKeyExpirationEpoch", + }, + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "AuthCognitoUserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + "CreateAPIKey": Object { + "Ref": "CreateAPIKey", + }, + "DynamoDBBillingMode": Object { + "Ref": "DynamoDBBillingMode", + }, + "DynamoDBEnablePointInTimeRecovery": Object { + "Ref": "DynamoDBEnablePointInTimeRecovery", + }, + "DynamoDBEnableServerSideEncryption": Object { + "Ref": "DynamoDBEnableServerSideEncryption", + }, + "DynamoDBModelTableReadIOPS": Object { + "Ref": "DynamoDBModelTableReadIOPS", + }, + "DynamoDBModelTableWriteIOPS": Object { + "Ref": "DynamoDBModelTableWriteIOPS", + }, + "GetAttGraphQLAPIApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + }, + "TemplateURL": PropertyDifference { + "changeImpact": "NO_CHANGE", + "isDifferent": false, + "newValue": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "Todo.json", + ], + ], + }, + "oldValue": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "Todo.json", + ], + ], + }, + }, + }, + "resourceTypes": Object { + "newType": "AWS::CloudFormation::Stack", + "oldType": "AWS::CloudFormation::Stack", + }, + }, + "Todo1": ResourceDifference { + "isAddition": true, + "isRemoval": false, + "newValue": Object { + "DependsOn": Array [ + "GraphQLSchema", + ], + "Properties": Object { + "Parameters": Object { + "APIKeyExpirationEpoch": Object { + "Ref": "APIKeyExpirationEpoch", + }, + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "AuthCognitoUserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + "CreateAPIKey": Object { + "Ref": "CreateAPIKey", + }, + "DynamoDBBillingMode": Object { + "Ref": "DynamoDBBillingMode", + }, + "DynamoDBEnablePointInTimeRecovery": Object { + "Ref": "DynamoDBEnablePointInTimeRecovery", + }, + "DynamoDBEnableServerSideEncryption": Object { + "Ref": "DynamoDBEnableServerSideEncryption", + }, + "DynamoDBModelTableReadIOPS": Object { + "Ref": "DynamoDBModelTableReadIOPS", + }, + "DynamoDBModelTableWriteIOPS": Object { + "Ref": "DynamoDBModelTableWriteIOPS", + }, + "GetAttGraphQLAPIApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "TemplateURL": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "Todo1.json", + ], + ], + }, + }, + "Type": "AWS::CloudFormation::Stack", + }, + "oldValue": undefined, + "otherDiffs": Object {}, + "propertyDiffs": Object {}, + "resourceTypes": Object { + "newType": "AWS::CloudFormation::Stack", + "oldType": undefined, + }, + }, + "Todo2": ResourceDifference { + "isAddition": true, + "isRemoval": false, + "newValue": Object { + "DependsOn": Array [ + "GraphQLSchema", + ], + "Properties": Object { + "Parameters": Object { + "APIKeyExpirationEpoch": Object { + "Ref": "APIKeyExpirationEpoch", + }, + "AppSyncApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "AppSyncApiName": Object { + "Ref": "AppSyncApiName", + }, + "AuthCognitoUserPoolId": Object { + "Ref": "AuthCognitoUserPoolId", + }, + "CreateAPIKey": Object { + "Ref": "CreateAPIKey", + }, + "DynamoDBBillingMode": Object { + "Ref": "DynamoDBBillingMode", + }, + "DynamoDBEnablePointInTimeRecovery": Object { + "Ref": "DynamoDBEnablePointInTimeRecovery", + }, + "DynamoDBEnableServerSideEncryption": Object { + "Ref": "DynamoDBEnableServerSideEncryption", + }, + "DynamoDBModelTableReadIOPS": Object { + "Ref": "DynamoDBModelTableReadIOPS", + }, + "DynamoDBModelTableWriteIOPS": Object { + "Ref": "DynamoDBModelTableWriteIOPS", + }, + "GetAttGraphQLAPIApiId": Object { + "Fn::GetAtt": Array [ + "GraphQLAPI", + "ApiId", + ], + }, + "S3DeploymentBucket": Object { + "Ref": "S3DeploymentBucket", + }, + "S3DeploymentRootKey": Object { + "Ref": "S3DeploymentRootKey", + }, + "env": Object { + "Ref": "env", + }, + }, + "TemplateURL": Object { + "Fn::Join": Array [ + "/", + Array [ + "https://s3.amazonaws.com", + Object { + "Ref": "S3DeploymentBucket", + }, + Object { + "Ref": "S3DeploymentRootKey", + }, + "stacks", + "Todo2.json", + ], + ], + }, + }, + "Type": "AWS::CloudFormation::Stack", + }, + "oldValue": undefined, + "otherDiffs": Object {}, + "propertyDiffs": Object {}, + "resourceTypes": Object { + "newType": "AWS::CloudFormation::Stack", + "oldType": undefined, + }, + }, + }, + }, + "securityGroupChanges": SecurityGroupChanges { + "egress": DiffableCollection { + "additions": Array [], + "newElements": Array [], + "oldElements": Array [], + "removals": Array [], + }, + "ingress": DiffableCollection { + "additions": Array [], + "newElements": Array [], + "oldElements": Array [], + "removals": Array [], + }, + }, + "unknown": DifferenceCollection { + "diffs": Object {}, + }, +} +`; diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status-diff.test.ts b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status-diff.test.ts new file mode 100644 index 00000000000..e20ec013117 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/resource-status-diff.test.ts @@ -0,0 +1,168 @@ +import { capitalize, + globCFNFilePath, + ResourceDiff, + stackMutationType} from '../../../extensions/amplify-helpers/resource-status-diff'; +import { CLOUD_INITIALIZED } from '../../../extensions/amplify-helpers/get-cloud-init-status'; +import glob from 'glob'; +import path from 'path'; +import * as fs from 'fs-extra'; +import { stateManager, pathManager} from 'amplify-cli-core'; + +//Mock Glob to fetch test cloudformation +jest.mock('glob'); +const localBackendDirPathStub = 'localBackendDirPath'; +const currentBackendDirPathStub = 'currentCloudBackendPath'; +const testApiName = 'testApiName'; +const globMock = glob as jest.Mocked; +const allFiles: string[] = [ "cloudformation-template.json", "parameters.json" , "resolvers", + "stacks" ,"functions", "pipelinesFunctions", "schema.graphql" ] +const templateMatchRegex = ".*template\.(json|yaml|yml)$" +globMock.sync.mockImplementation(() => allFiles.filter( fname => fname.match(templateMatchRegex) )); + + +//Mock fs to pass all file-system checks +jest.mock('fs-extra', () => ({ + ...(jest.requireActual('fs-extra') as {}), + existsSync: jest.fn().mockImplementation(()=> true), + statSync: jest.fn().mockReturnValue({ isFile: ()=>true } as fs.Stats ) +})); + + + +describe('resource-status-diff helpers', () => { + + beforeAll(() => { + jest.unmock('amplify-cli-core'); + }) + + it('capitalize should capitalize strings', async () => { + const mockInput = "abcd"; + const expectedOutput = "Abcd"; + expect(capitalize(mockInput)).toBe( expectedOutput ); + }); + + it('should Glob only cloudformation template files ', async () => { + + const mockCloudformationTemplateName = "cloudformation-template.json" + const stubFileFolder = "stub-file-folder" + const expectedGlobOptions = { + absolute: false, + cwd: stubFileFolder, + follow: false, + nodir: true, + }; + + const cfnFilename = globCFNFilePath( stubFileFolder ); + expect(globMock.sync.mock.calls.length).toBe(1); + expect(globMock.sync).toBeCalledWith('**/*template.{yaml,yml,json}', expectedGlobOptions); + expect( cfnFilename ).toBe(`${stubFileFolder}/${mockCloudformationTemplateName}`); + }); + + it('should search both Build and non Build folders for Cloudformation Templates ', async () => { + const mockGraphQLAPIMeta = { + providers: { + awscloudformation: { + Region: 'myMockRegion', + }, + }, + 'api': { + [testApiName] : { + service : 'AppSync' + } + } + }; + + //Enable cloud initialized to test updates , but retain all other functions + jest.mock('../../../extensions/amplify-helpers/get-cloud-init-status', () => ({ + ...(jest.requireActual('../../../extensions/amplify-helpers/get-cloud-init-status') as {}), + getCloudInitStatus: jest.fn().mockImplementation(()=> CLOUD_INITIALIZED), + })); + + //helper to mock common dependencies + const setMockTestCommonDependencies = ()=>{ + jest.mock('amplify-cli-core'); + const pathManagerMock = pathManager as jest.Mocked; + pathManagerMock.getBackendDirPath = jest.fn().mockImplementation(() => localBackendDirPathStub); + pathManagerMock.getCurrentCloudBackendDirPath = jest.fn().mockImplementation(() => currentBackendDirPathStub); + + const stateManagerMock = stateManager as jest.Mocked; + stateManagerMock.getMeta = jest.fn().mockImplementation(()=> mockGraphQLAPIMeta); + stateManagerMock.getCurrentMeta = jest.fn().mockImplementation(()=> mockGraphQLAPIMeta); + + } + + const getMockInputData = ()=>{ + + return { + mockDefaultRootCfnTmpltName : "cloudformation-template.json", + mockCategory : "api", + mockResourceName : testApiName, + mockProvider : "awscloudformation", + normalizedProvider : "cloudformation", + mockMutationInfo: stackMutationType.UPDATE + } + } + + //Test mocks + setMockTestCommonDependencies(); + const input = getMockInputData(); + + /** 1. Code-Under-Test - constructor **/ + const resourceDiff = new ResourceDiff(input.mockCategory, input.mockResourceName, input.mockProvider , input.mockMutationInfo); + + //Test Definitions + const checkProviderNormalization = ()=> { + expect(resourceDiff.provider ).toBe(input.normalizedProvider); + } + + const checkCfnPaths = ()=> { + const mockLocalPreBuildCfnFile = path.join(localBackendDirPathStub, input.mockCategory, input.mockResourceName, input.mockDefaultRootCfnTmpltName); + const mockCurrentPreBuildCfnFile = path.join(currentBackendDirPathStub, input.mockCategory, input.mockResourceName, input.mockDefaultRootCfnTmpltName); + const mockLocalPostBuildCfnFile = path.join(localBackendDirPathStub,input.mockCategory, input.mockResourceName,'build', input.mockDefaultRootCfnTmpltName); + const mockCurrentPostBuildCfnFile = path.join(currentBackendDirPathStub,input.mockCategory, input.mockResourceName,'build', input.mockDefaultRootCfnTmpltName); + expect(resourceDiff.resourceFiles.localPreBuildCfnFile).toBe(mockLocalPreBuildCfnFile); + expect(resourceDiff.resourceFiles.cloudPreBuildCfnFile).toBe(mockCurrentPreBuildCfnFile); + expect(resourceDiff.resourceFiles.localBuildCfnFile).toBe(mockLocalPostBuildCfnFile); + expect(resourceDiff.resourceFiles.cloudBuildCfnFile).toBe(mockCurrentPostBuildCfnFile); + } + + //Test Execution + checkProviderNormalization(); + checkCfnPaths(); + + + }) + + it ( 'should show the diff between local and remote cloudformation ', async ()=>{ + + const getMockInputData = ()=>{ + + return { + mockDefaultRootCfnTmpltName : "cloudformation-template.json", + mockCategory : "api", + mockResourceName : testApiName, + mockProvider : "awscloudformation", + normalizedProvider : "cloudformation", + mockMutationInfo: stackMutationType.UPDATE + } + } + + const input = getMockInputData(); + + /** 1. Code-Under-Test - constructor **/ + const resourceDiff = new ResourceDiff(input.mockCategory, input.mockResourceName, input.mockProvider , input.mockMutationInfo); + + /** 2. Code-Under-Test calculateCfnDiff **/ + //update sample cloudformation paths in resourceDiff + const localPath = `${__dirname}/testData/mockLocalCloud/cloudformation-template.json`; + const cloudPath = `${__dirname}/testData/mockCurrentCloud/cloudformation-template.json`; + //override paths to point to reference cloudformation templates + resourceDiff.resourceFiles.localBuildCfnFile = localPath; + resourceDiff.resourceFiles.cloudBuildCfnFile = cloudPath; + resourceDiff.resourceFiles.localPreBuildCfnFile = localPath; + resourceDiff.resourceFiles.cloudPreBuildCfnFile = cloudPath; + const diff = await resourceDiff.calculateCfnDiff(); + expect( diff ).toMatchSnapshot(); + }) + +}); \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/CustomResources.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/CustomResources.json new file mode 100644 index 00000000000..dc7e8a0d402 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/CustomResources.json @@ -0,0 +1,61 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "AppSyncApiId": { + "Type": "String", + "Description": "The id of the AppSync API associated with this project." + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." + } + }, + "Resources": { + "EmptyResource": { + "Type": "Custom::EmptyResource", + "Condition": "AlwaysFalse" + } + }, + "Conditions": { + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + }, + "AlwaysFalse": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Outputs": { + "EmptyOutput": { + "Description": "An empty output. You may delete this if you have at least one resource above.", + "Value": "" + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/Todo.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/Todo.json new file mode 100644 index 00000000000..751b1793a2c --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/Todo.json @@ -0,0 +1,854 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "DynamoDBModelTableReadIOPS": { + "Type": "Number", + "Description": "The number of read IOPS the table should support.", + "Default": 5 + }, + "DynamoDBModelTableWriteIOPS": { + "Type": "Number", + "Description": "The number of write IOPS the table should support.", + "Default": 5 + }, + "DynamoDBBillingMode": { + "Type": "String", + "Description": "Configure @model types to create DynamoDB tables with PAY_PER_REQUEST or PROVISIONED billing modes.", + "Default": "PAY_PER_REQUEST", + "AllowedValues": [ + "PAY_PER_REQUEST", + "PROVISIONED" + ] + }, + "DynamoDBEnablePointInTimeRecovery": { + "Type": "String", + "Description": "Whether to enable Point in Time Recovery on the table", + "Default": "false", + "AllowedValues": [ + "true", + "false" + ] + }, + "DynamoDBEnableServerSideEncryption": { + "Type": "String", + "Description": "Enable server side encryption powered by KMS.", + "Default": "true", + "AllowedValues": [ + "true", + "false" + ] + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "APIKeyExpirationEpoch": { + "Type": "Number", + "Description": "The epoch time in seconds when the API Key should expire. Setting this to 0 will default to 7 days from the deployment date. Setting this to -1 will not create an API Key.", + "Default": 0, + "MinValue": -1 + }, + "CreateAPIKey": { + "Type": "Number", + "Description": "The boolean value to control if an API Key will be created or not. The value of the property is automatically set by the CLI. If the value is set to 0 no API Key will be created.", + "Default": 0, + "MinValue": 0, + "MaxValue": 1 + }, + "AuthCognitoUserPoolId": { + "Type": "String", + "Description": "The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.", + "Default": "NONE" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root of the deployment directory." + }, + "AppSyncApiId": { + "Type": "String", + "Description": "The id of the AppSync API associated with this project." + }, + "GetAttGraphQLAPIApiId": { + "Type": "String", + "Description": "Auto-generated parameter that forwards Fn.GetAtt(GraphQLAPI, ApiId) through to nested stacks." + } + }, + "Resources": { + "TodoTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "BillingMode": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + "PAY_PER_REQUEST", + { + "Ref": "AWS::NoValue" + } + ] + }, + "ProvisionedThroughput": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + { + "Ref": "AWS::NoValue" + }, + { + "ReadCapacityUnits": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "WriteCapacityUnits": { + "Ref": "DynamoDBModelTableWriteIOPS" + } + } + ] + }, + "SSESpecification": { + "SSEEnabled": { + "Fn::If": [ + "ShouldUseServerSideEncryption", + true, + false + ] + } + }, + "PointInTimeRecoverySpecification": { + "Fn::If": [ + "ShouldUsePointInTimeRecovery", + { + "PointInTimeRecoveryEnabled": true + }, + { + "Ref": "AWS::NoValue" + } + ] + } + }, + "DeletionPolicy": "Delete" + }, + "TodoIAMRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo0107c6", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo52029a", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "appsync.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Policies": [ + { + "PolicyName": "DynamoDBAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:UpdateItem" + ], + "Resource": [ + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + } + ] + } + ] + } + } + ] + } + }, + "TodoDataSource": { + "Type": "AWS::AppSync::DataSource", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "Name": "TodoTable", + "Type": "AMAZON_DYNAMODB", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "TodoIAMRole", + "Arn" + ] + }, + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region" + }, + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + }, + "DependsOn": [ + "TodoIAMRole" + ] + }, + "GetTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "getTodo", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "ListTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "listTodos", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodos", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodos", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "CreateTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "createTodo", + "TypeName": "Mutation", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "UpdateTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "updateTodo", + "TypeName": "Mutation", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "DeleteTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "deleteTodo", + "TypeName": "Mutation", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + } + }, + "Outputs": { + "GetAttTodoTableStreamArn": { + "Description": "Your DynamoDB table StreamArn.", + "Value": { + "Fn::GetAtt": [ + "TodoTable", + "StreamArn" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "TodoTable", + "StreamArn" + ] + ] + } + } + }, + "GetAttTodoDataSourceName": { + "Description": "Your model DataSource name.", + "Value": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "TodoDataSource", + "Name" + ] + ] + } + } + }, + "GetAttTodoTableName": { + "Description": "Your DynamoDB table name.", + "Value": { + "Ref": "TodoTable" + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "TodoTable", + "Name" + ] + ] + } + } + } + }, + "Mappings": {}, + "Conditions": { + "ShouldUsePayPerRequestBilling": { + "Fn::Equals": [ + { + "Ref": "DynamoDBBillingMode" + }, + "PAY_PER_REQUEST" + ] + }, + "ShouldUsePointInTimeRecovery": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "true" + ] + }, + "ShouldUseServerSideEncryption": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "true" + ] + }, + "ShouldCreateAPIKey": { + "Fn::Equals": [ + { + "Ref": "CreateAPIKey" + }, + 1 + ] + }, + "APIKeyExpirationEpochIsPositive": { + "Fn::And": [ + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + -1 + ] + } + ] + }, + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + 0 + ] + } + ] + } + ] + }, + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/cloudformation-template.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/cloudformation-template.json new file mode 100644 index 00000000000..58b962ab0e1 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/cloudformation-template.json @@ -0,0 +1,419 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "DynamoDBModelTableReadIOPS": { + "Type": "Number", + "Description": "The number of read IOPS the table should support.", + "Default": 5 + }, + "DynamoDBModelTableWriteIOPS": { + "Type": "Number", + "Description": "The number of write IOPS the table should support.", + "Default": 5 + }, + "DynamoDBBillingMode": { + "Type": "String", + "Description": "Configure @model types to create DynamoDB tables with PAY_PER_REQUEST or PROVISIONED billing modes.", + "Default": "PAY_PER_REQUEST", + "AllowedValues": [ + "PAY_PER_REQUEST", + "PROVISIONED" + ] + }, + "DynamoDBEnablePointInTimeRecovery": { + "Type": "String", + "Description": "Whether to enable Point in Time Recovery on the table", + "Default": "false", + "AllowedValues": [ + "true", + "false" + ] + }, + "DynamoDBEnableServerSideEncryption": { + "Type": "String", + "Description": "Enable server side encryption powered by KMS.", + "Default": "true", + "AllowedValues": [ + "true", + "false" + ] + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "APIKeyExpirationEpoch": { + "Type": "Number", + "Description": "The epoch time in seconds when the API Key should expire. Setting this to 0 will default to 7 days from the deployment date. Setting this to -1 will not create an API Key.", + "Default": 0, + "MinValue": -1 + }, + "CreateAPIKey": { + "Type": "Number", + "Description": "The boolean value to control if an API Key will be created or not. The value of the property is automatically set by the CLI. If the value is set to 0 no API Key will be created.", + "Default": 0, + "MinValue": 0, + "MaxValue": 1 + }, + "AuthCognitoUserPoolId": { + "Type": "String", + "Description": "The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.", + "Default": "NONE" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root of the deployment directory." + } + }, + "Resources": { + "GraphQLAPI": { + "Type": "AWS::AppSync::GraphQLApi", + "Properties": { + "Name": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + { + "Ref": "AppSyncApiName" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Ref": "AppSyncApiName" + } + ] + }, + "AuthenticationType": "API_KEY" + } + }, + "GraphQLAPIKey": { + "Type": "AWS::AppSync::ApiKey", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "Description": "demo", + "Expires": { + "Fn::If": [ + "APIKeyExpirationEpochIsPositive", + { + "Ref": "APIKeyExpirationEpoch" + }, + 1624238916 + ] + } + }, + "Condition": "ShouldCreateAPIKey" + }, + "GraphQLSchema": { + "Type": "AWS::AppSync::GraphQLSchema", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "DefinitionS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/schema.graphql", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + } + } + ] + } + } + }, + "Todo": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "AppSyncApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "DynamoDBModelTableReadIOPS": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "DynamoDBModelTableWriteIOPS": { + "Ref": "DynamoDBModelTableWriteIOPS" + }, + "DynamoDBBillingMode": { + "Ref": "DynamoDBBillingMode" + }, + "DynamoDBEnablePointInTimeRecovery": { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "DynamoDBEnableServerSideEncryption": { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "AppSyncApiName": { + "Ref": "AppSyncApiName" + }, + "APIKeyExpirationEpoch": { + "Ref": "APIKeyExpirationEpoch" + }, + "CreateAPIKey": { + "Ref": "CreateAPIKey" + }, + "AuthCognitoUserPoolId": { + "Ref": "AuthCognitoUserPoolId" + }, + "env": { + "Ref": "env" + }, + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "GetAttGraphQLAPIApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + } + }, + "TemplateURL": { + "Fn::Join": [ + "/", + [ + "https://s3.amazonaws.com", + { + "Ref": "S3DeploymentBucket" + }, + { + "Ref": "S3DeploymentRootKey" + }, + "stacks", + "Todo.json" + ] + ] + } + }, + "DependsOn": [ + "GraphQLSchema" + ] + }, + "CustomResourcesjson": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "AppSyncApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "AppSyncApiName": { + "Ref": "AppSyncApiName" + }, + "env": { + "Ref": "env" + }, + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + } + }, + "TemplateURL": { + "Fn::Join": [ + "/", + [ + "https://s3.amazonaws.com", + { + "Ref": "S3DeploymentBucket" + }, + { + "Ref": "S3DeploymentRootKey" + }, + "stacks", + "CustomResources.json" + ] + ] + } + }, + "DependsOn": [ + "GraphQLAPI", + "GraphQLSchema", + "Todo" + ] + } + }, + "Outputs": { + "GraphQLAPIIdOutput": { + "Description": "Your GraphQL API ID.", + "Value": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AWS::StackName" + }, + "GraphQLApiId" + ] + ] + } + } + }, + "GraphQLAPIEndpointOutput": { + "Description": "Your GraphQL API endpoint.", + "Value": { + "Fn::GetAtt": [ + "GraphQLAPI", + "GraphQLUrl" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AWS::StackName" + }, + "GraphQLApiEndpoint" + ] + ] + } + } + }, + "GraphQLAPIKeyOutput": { + "Description": "Your GraphQL API key. Provide via 'x-api-key' header.", + "Value": { + "Fn::GetAtt": [ + "GraphQLAPIKey", + "ApiKey" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AWS::StackName" + }, + "GraphQLApiKey" + ] + ] + } + }, + "Condition": "ShouldCreateAPIKey" + } + }, + "Mappings": {}, + "Conditions": { + "ShouldUsePayPerRequestBilling": { + "Fn::Equals": [ + { + "Ref": "DynamoDBBillingMode" + }, + "PAY_PER_REQUEST" + ] + }, + "ShouldUsePointInTimeRecovery": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "true" + ] + }, + "ShouldUseServerSideEncryption": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "true" + ] + }, + "ShouldCreateAPIKey": { + "Fn::Equals": [ + { + "Ref": "CreateAPIKey" + }, + 1 + ] + }, + "APIKeyExpirationEpochIsPositive": { + "Fn::And": [ + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + -1 + ] + } + ] + }, + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + 0 + ] + } + ] + } + ] + }, + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/parameters.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/parameters.json new file mode 100644 index 00000000000..d47ac917f7c --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockCurrentCloud/parameters.json @@ -0,0 +1,8 @@ +{ + "CreateAPIKey": 1, + "AppSyncApiName": "lil", + "DynamoDBBillingMode": "PAY_PER_REQUEST", + "DynamoDBEnableServerSideEncryption": false, + "S3DeploymentBucket": "amplify-lil-dev-182009-deployment", + "S3DeploymentRootKey": "amplify-appsync-files/6d93be57c5a95eab736c67e102245348eddd44cb" +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/CustomResources.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/CustomResources.json new file mode 100644 index 00000000000..dc7e8a0d402 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/CustomResources.json @@ -0,0 +1,61 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "AppSyncApiId": { + "Type": "String", + "Description": "The id of the AppSync API associated with this project." + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." + } + }, + "Resources": { + "EmptyResource": { + "Type": "Custom::EmptyResource", + "Condition": "AlwaysFalse" + } + }, + "Conditions": { + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + }, + "AlwaysFalse": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Outputs": { + "EmptyOutput": { + "Description": "An empty output. You may delete this if you have at least one resource above.", + "Value": "" + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo.json new file mode 100644 index 00000000000..3c67ca23c38 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo.json @@ -0,0 +1,1043 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "DynamoDBModelTableReadIOPS": { + "Type": "Number", + "Description": "The number of read IOPS the table should support.", + "Default": 5 + }, + "DynamoDBModelTableWriteIOPS": { + "Type": "Number", + "Description": "The number of write IOPS the table should support.", + "Default": 5 + }, + "DynamoDBBillingMode": { + "Type": "String", + "Description": "Configure @model types to create DynamoDB tables with PAY_PER_REQUEST or PROVISIONED billing modes.", + "Default": "PAY_PER_REQUEST", + "AllowedValues": [ + "PAY_PER_REQUEST", + "PROVISIONED" + ] + }, + "DynamoDBEnablePointInTimeRecovery": { + "Type": "String", + "Description": "Whether to enable Point in Time Recovery on the table", + "Default": "false", + "AllowedValues": [ + "true", + "false" + ] + }, + "DynamoDBEnableServerSideEncryption": { + "Type": "String", + "Description": "Enable server side encryption powered by KMS.", + "Default": "true", + "AllowedValues": [ + "true", + "false" + ] + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "APIKeyExpirationEpoch": { + "Type": "Number", + "Description": "The epoch time in seconds when the API Key should expire. Setting this to 0 will default to 7 days from the deployment date. Setting this to -1 will not create an API Key.", + "Default": 0, + "MinValue": -1 + }, + "CreateAPIKey": { + "Type": "Number", + "Description": "The boolean value to control if an API Key will be created or not. The value of the property is automatically set by the CLI. If the value is set to 0 no API Key will be created.", + "Default": 0, + "MinValue": 0, + "MaxValue": 1 + }, + "AuthCognitoUserPoolId": { + "Type": "String", + "Description": "The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.", + "Default": "NONE" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root of the deployment directory." + }, + "AppSyncApiId": { + "Type": "String", + "Description": "The id of the AppSync API associated with this project." + }, + "GetAttGraphQLAPIApiId": { + "Type": "String", + "Description": "Auto-generated parameter that forwards Fn.GetAtt(GraphQLAPI, ApiId) through to nested stacks." + } + }, + "Resources": { + "TodoTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "BillingMode": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + "PAY_PER_REQUEST", + { + "Ref": "AWS::NoValue" + } + ] + }, + "ProvisionedThroughput": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + { + "Ref": "AWS::NoValue" + }, + { + "ReadCapacityUnits": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "WriteCapacityUnits": { + "Ref": "DynamoDBModelTableWriteIOPS" + } + } + ] + }, + "SSESpecification": { + "SSEEnabled": { + "Fn::If": [ + "ShouldUseServerSideEncryption", + true, + false + ] + } + }, + "PointInTimeRecoverySpecification": { + "Fn::If": [ + "ShouldUsePointInTimeRecovery", + { + "PointInTimeRecoveryEnabled": true + }, + { + "Ref": "AWS::NoValue" + } + ] + }, + "TimeToLiveSpecification": { + "AttributeName": "_ttl", + "Enabled": true + } + }, + "DeletionPolicy": "Delete" + }, + "TodoIAMRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo0107c6", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo52029a", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "appsync.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Policies": [ + { + "PolicyName": "DynamoDBAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:UpdateItem" + ], + "Resource": [ + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + } + ] + } + ] + } + } + ] + } + }, + "TodoDataSource": { + "Type": "AWS::AppSync::DataSource", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "Name": "TodoTable", + "Type": "AMAZON_DYNAMODB", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "TodoIAMRole", + "Arn" + ] + }, + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region" + }, + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "DeltaSyncConfig": { + "DeltaSyncTableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "DeltaSyncTableTTL": 30, + "BaseTableTTL": 43200 + }, + "Versioned": true + } + }, + "DependsOn": [ + "TodoIAMRole" + ] + }, + "SyncTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "syncTodos", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "syncTodos", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "syncTodos", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "GetTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "getTodo", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "ListTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "listTodos", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodos", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodos", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "CreateTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "createTodo", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "UpdateTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "updateTodo", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "DeleteTodoResolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "FieldName": "deleteTodo", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo", + "res", + "vtl" + ] + ] + } + } + ] + } + } + } + }, + "Outputs": { + "GetAttTodoTableStreamArn": { + "Description": "Your DynamoDB table StreamArn.", + "Value": { + "Fn::GetAtt": [ + "TodoTable", + "StreamArn" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "TodoTable", + "StreamArn" + ] + ] + } + } + }, + "GetAttTodoDataSourceName": { + "Description": "Your model DataSource name.", + "Value": { + "Fn::GetAtt": [ + "TodoDataSource", + "Name" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "TodoDataSource", + "Name" + ] + ] + } + } + }, + "GetAttTodoTableName": { + "Description": "Your DynamoDB table name.", + "Value": { + "Ref": "TodoTable" + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "TodoTable", + "Name" + ] + ] + } + } + } + }, + "Mappings": {}, + "Conditions": { + "ShouldUsePayPerRequestBilling": { + "Fn::Equals": [ + { + "Ref": "DynamoDBBillingMode" + }, + "PAY_PER_REQUEST" + ] + }, + "ShouldUsePointInTimeRecovery": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "true" + ] + }, + "ShouldUseServerSideEncryption": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "true" + ] + }, + "ShouldCreateAPIKey": { + "Fn::Equals": [ + { + "Ref": "CreateAPIKey" + }, + 1 + ] + }, + "APIKeyExpirationEpochIsPositive": { + "Fn::And": [ + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + -1 + ] + } + ] + }, + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + 0 + ] + } + ] + } + ] + }, + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo1.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo1.json new file mode 100644 index 00000000000..d010fc158a4 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo1.json @@ -0,0 +1,1043 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "DynamoDBModelTableReadIOPS": { + "Type": "Number", + "Description": "The number of read IOPS the table should support.", + "Default": 5 + }, + "DynamoDBModelTableWriteIOPS": { + "Type": "Number", + "Description": "The number of write IOPS the table should support.", + "Default": 5 + }, + "DynamoDBBillingMode": { + "Type": "String", + "Description": "Configure @model types to create DynamoDB tables with PAY_PER_REQUEST or PROVISIONED billing modes.", + "Default": "PAY_PER_REQUEST", + "AllowedValues": [ + "PAY_PER_REQUEST", + "PROVISIONED" + ] + }, + "DynamoDBEnablePointInTimeRecovery": { + "Type": "String", + "Description": "Whether to enable Point in Time Recovery on the table", + "Default": "false", + "AllowedValues": [ + "true", + "false" + ] + }, + "DynamoDBEnableServerSideEncryption": { + "Type": "String", + "Description": "Enable server side encryption powered by KMS.", + "Default": "true", + "AllowedValues": [ + "true", + "false" + ] + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "APIKeyExpirationEpoch": { + "Type": "Number", + "Description": "The epoch time in seconds when the API Key should expire. Setting this to 0 will default to 7 days from the deployment date. Setting this to -1 will not create an API Key.", + "Default": 0, + "MinValue": -1 + }, + "CreateAPIKey": { + "Type": "Number", + "Description": "The boolean value to control if an API Key will be created or not. The value of the property is automatically set by the CLI. If the value is set to 0 no API Key will be created.", + "Default": 0, + "MinValue": 0, + "MaxValue": 1 + }, + "AuthCognitoUserPoolId": { + "Type": "String", + "Description": "The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.", + "Default": "NONE" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root of the deployment directory." + }, + "AppSyncApiId": { + "Type": "String", + "Description": "The id of the AppSync API associated with this project." + }, + "GetAttGraphQLAPIApiId": { + "Type": "String", + "Description": "Auto-generated parameter that forwards Fn.GetAtt(GraphQLAPI, ApiId) through to nested stacks." + } + }, + "Resources": { + "Todo1Table": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "BillingMode": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + "PAY_PER_REQUEST", + { + "Ref": "AWS::NoValue" + } + ] + }, + "ProvisionedThroughput": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + { + "Ref": "AWS::NoValue" + }, + { + "ReadCapacityUnits": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "WriteCapacityUnits": { + "Ref": "DynamoDBModelTableWriteIOPS" + } + } + ] + }, + "SSESpecification": { + "SSEEnabled": { + "Fn::If": [ + "ShouldUseServerSideEncryption", + true, + false + ] + } + }, + "PointInTimeRecoverySpecification": { + "Fn::If": [ + "ShouldUsePointInTimeRecovery", + { + "PointInTimeRecoveryEnabled": true + }, + { + "Ref": "AWS::NoValue" + } + ] + }, + "TimeToLiveSpecification": { + "AttributeName": "_ttl", + "Enabled": true + } + }, + "DeletionPolicy": "Delete" + }, + "Todo1IAMRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo159b4e7", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo1dc0bc0", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "appsync.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Policies": [ + { + "PolicyName": "DynamoDBAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:UpdateItem" + ], + "Resource": [ + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + } + ] + } + ] + } + } + ] + } + }, + "Todo1DataSource": { + "Type": "AWS::AppSync::DataSource", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "Name": "Todo1Table", + "Type": "AMAZON_DYNAMODB", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "Todo1IAMRole", + "Arn" + ] + }, + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region" + }, + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo1", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "DeltaSyncConfig": { + "DeltaSyncTableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "DeltaSyncTableTTL": 30, + "BaseTableTTL": 43200 + }, + "Versioned": true + } + }, + "DependsOn": [ + "Todo1IAMRole" + ] + }, + "SyncTodo1Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "FieldName": "syncTodo1s", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "syncTodo1s", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "syncTodo1s", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "GetTodo1Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "FieldName": "getTodo1", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo1", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo1", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "ListTodo1Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "FieldName": "listTodo1s", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodo1s", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodo1s", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "CreateTodo1Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "FieldName": "createTodo1", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo1", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo1", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "UpdateTodo1Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "FieldName": "updateTodo1", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo1", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo1", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "DeleteTodo1Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "FieldName": "deleteTodo1", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo1", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo1", + "res", + "vtl" + ] + ] + } + } + ] + } + } + } + }, + "Outputs": { + "GetAttTodo1TableStreamArn": { + "Description": "Your DynamoDB table StreamArn.", + "Value": { + "Fn::GetAtt": [ + "Todo1Table", + "StreamArn" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "Todo1Table", + "StreamArn" + ] + ] + } + } + }, + "GetAttTodo1DataSourceName": { + "Description": "Your model DataSource name.", + "Value": { + "Fn::GetAtt": [ + "Todo1DataSource", + "Name" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "Todo1DataSource", + "Name" + ] + ] + } + } + }, + "GetAttTodo1TableName": { + "Description": "Your DynamoDB table name.", + "Value": { + "Ref": "Todo1Table" + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "Todo1Table", + "Name" + ] + ] + } + } + } + }, + "Mappings": {}, + "Conditions": { + "ShouldUsePayPerRequestBilling": { + "Fn::Equals": [ + { + "Ref": "DynamoDBBillingMode" + }, + "PAY_PER_REQUEST" + ] + }, + "ShouldUsePointInTimeRecovery": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "true" + ] + }, + "ShouldUseServerSideEncryption": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "true" + ] + }, + "ShouldCreateAPIKey": { + "Fn::Equals": [ + { + "Ref": "CreateAPIKey" + }, + 1 + ] + }, + "APIKeyExpirationEpochIsPositive": { + "Fn::And": [ + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + -1 + ] + } + ] + }, + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + 0 + ] + } + ] + } + ] + }, + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo2.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo2.json new file mode 100644 index 00000000000..bffc8105741 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/Todo2.json @@ -0,0 +1,1043 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "DynamoDBModelTableReadIOPS": { + "Type": "Number", + "Description": "The number of read IOPS the table should support.", + "Default": 5 + }, + "DynamoDBModelTableWriteIOPS": { + "Type": "Number", + "Description": "The number of write IOPS the table should support.", + "Default": 5 + }, + "DynamoDBBillingMode": { + "Type": "String", + "Description": "Configure @model types to create DynamoDB tables with PAY_PER_REQUEST or PROVISIONED billing modes.", + "Default": "PAY_PER_REQUEST", + "AllowedValues": [ + "PAY_PER_REQUEST", + "PROVISIONED" + ] + }, + "DynamoDBEnablePointInTimeRecovery": { + "Type": "String", + "Description": "Whether to enable Point in Time Recovery on the table", + "Default": "false", + "AllowedValues": [ + "true", + "false" + ] + }, + "DynamoDBEnableServerSideEncryption": { + "Type": "String", + "Description": "Enable server side encryption powered by KMS.", + "Default": "true", + "AllowedValues": [ + "true", + "false" + ] + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "APIKeyExpirationEpoch": { + "Type": "Number", + "Description": "The epoch time in seconds when the API Key should expire. Setting this to 0 will default to 7 days from the deployment date. Setting this to -1 will not create an API Key.", + "Default": 0, + "MinValue": -1 + }, + "CreateAPIKey": { + "Type": "Number", + "Description": "The boolean value to control if an API Key will be created or not. The value of the property is automatically set by the CLI. If the value is set to 0 no API Key will be created.", + "Default": 0, + "MinValue": 0, + "MaxValue": 1 + }, + "AuthCognitoUserPoolId": { + "Type": "String", + "Description": "The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.", + "Default": "NONE" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root of the deployment directory." + }, + "AppSyncApiId": { + "Type": "String", + "Description": "The id of the AppSync API associated with this project." + }, + "GetAttGraphQLAPIApiId": { + "Type": "String", + "Description": "Auto-generated parameter that forwards Fn.GetAtt(GraphQLAPI, ApiId) through to nested stacks." + } + }, + "Resources": { + "Todo2Table": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "BillingMode": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + "PAY_PER_REQUEST", + { + "Ref": "AWS::NoValue" + } + ] + }, + "ProvisionedThroughput": { + "Fn::If": [ + "ShouldUsePayPerRequestBilling", + { + "Ref": "AWS::NoValue" + }, + { + "ReadCapacityUnits": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "WriteCapacityUnits": { + "Ref": "DynamoDBModelTableWriteIOPS" + } + } + ] + }, + "SSESpecification": { + "SSEEnabled": { + "Fn::If": [ + "ShouldUseServerSideEncryption", + true, + false + ] + } + }, + "PointInTimeRecoverySpecification": { + "Fn::If": [ + "ShouldUsePointInTimeRecovery", + { + "PointInTimeRecoveryEnabled": true + }, + { + "Ref": "AWS::NoValue" + } + ] + }, + "TimeToLiveSpecification": { + "AttributeName": "_ttl", + "Enabled": true + } + }, + "DeletionPolicy": "Delete" + }, + "Todo2IAMRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo2223300", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo2c64cc9", + "role", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "appsync.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Policies": [ + { + "PolicyName": "DynamoDBAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:UpdateItem" + ], + "Resource": [ + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + }, + { + "Fn::Sub": [ + "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tablename}/*", + { + "tablename": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + } + } + ] + } + ] + } + ] + } + } + ] + } + }, + "Todo2DataSource": { + "Type": "AWS::AppSync::DataSource", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "Name": "Todo2Table", + "Type": "AMAZON_DYNAMODB", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "Todo2IAMRole", + "Arn" + ] + }, + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region" + }, + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "Todo2", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "DeltaSyncConfig": { + "DeltaSyncTableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Ref": "GetAttGraphQLAPIApiId" + } + ] + ] + } + ] + }, + "DeltaSyncTableTTL": 30, + "BaseTableTTL": 43200 + }, + "Versioned": true + } + }, + "DependsOn": [ + "Todo2IAMRole" + ] + }, + "SyncTodo2Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "FieldName": "syncTodo2s", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "syncTodo2s", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "syncTodo2s", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "GetTodo2Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "FieldName": "getTodo2", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo2", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "getTodo2", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "ListTodo2Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "FieldName": "listTodo2s", + "TypeName": "Query", + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodo2s", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Query", + "listTodo2s", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "CreateTodo2Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "FieldName": "createTodo2", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo2", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "createTodo2", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "UpdateTodo2Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "FieldName": "updateTodo2", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo2", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "updateTodo2", + "res", + "vtl" + ] + ] + } + } + ] + } + } + }, + "DeleteTodo2Resolver": { + "Type": "AWS::AppSync::Resolver", + "Properties": { + "ApiId": { + "Ref": "GetAttGraphQLAPIApiId" + }, + "DataSourceName": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "FieldName": "deleteTodo2", + "TypeName": "Mutation", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "AUTOMERGE" + }, + "RequestMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo2", + "req", + "vtl" + ] + ] + } + } + ] + }, + "ResponseMappingTemplateS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "ResolverFileName": { + "Fn::Join": [ + ".", + [ + "Mutation", + "deleteTodo2", + "res", + "vtl" + ] + ] + } + } + ] + } + } + } + }, + "Outputs": { + "GetAttTodo2TableStreamArn": { + "Description": "Your DynamoDB table StreamArn.", + "Value": { + "Fn::GetAtt": [ + "Todo2Table", + "StreamArn" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "Todo2Table", + "StreamArn" + ] + ] + } + } + }, + "GetAttTodo2DataSourceName": { + "Description": "Your model DataSource name.", + "Value": { + "Fn::GetAtt": [ + "Todo2DataSource", + "Name" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "Todo2DataSource", + "Name" + ] + ] + } + } + }, + "GetAttTodo2TableName": { + "Description": "Your DynamoDB table name.", + "Value": { + "Ref": "Todo2Table" + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AppSyncApiId" + }, + "GetAtt", + "Todo2Table", + "Name" + ] + ] + } + } + } + }, + "Mappings": {}, + "Conditions": { + "ShouldUsePayPerRequestBilling": { + "Fn::Equals": [ + { + "Ref": "DynamoDBBillingMode" + }, + "PAY_PER_REQUEST" + ] + }, + "ShouldUsePointInTimeRecovery": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "true" + ] + }, + "ShouldUseServerSideEncryption": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "true" + ] + }, + "ShouldCreateAPIKey": { + "Fn::Equals": [ + { + "Ref": "CreateAPIKey" + }, + 1 + ] + }, + "APIKeyExpirationEpochIsPositive": { + "Fn::And": [ + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + -1 + ] + } + ] + }, + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + 0 + ] + } + ] + } + ] + }, + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/cloudformation-template.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/cloudformation-template.json new file mode 100644 index 00000000000..c4b0a54dfee --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/cloudformation-template.json @@ -0,0 +1,643 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "An auto-generated nested stack.", + "Metadata": {}, + "Parameters": { + "DynamoDBModelTableReadIOPS": { + "Type": "Number", + "Description": "The number of read IOPS the table should support.", + "Default": 5 + }, + "DynamoDBModelTableWriteIOPS": { + "Type": "Number", + "Description": "The number of write IOPS the table should support.", + "Default": 5 + }, + "DynamoDBBillingMode": { + "Type": "String", + "Description": "Configure @model types to create DynamoDB tables with PAY_PER_REQUEST or PROVISIONED billing modes.", + "Default": "PAY_PER_REQUEST", + "AllowedValues": [ + "PAY_PER_REQUEST", + "PROVISIONED" + ] + }, + "DynamoDBEnablePointInTimeRecovery": { + "Type": "String", + "Description": "Whether to enable Point in Time Recovery on the table", + "Default": "false", + "AllowedValues": [ + "true", + "false" + ] + }, + "DynamoDBEnableServerSideEncryption": { + "Type": "String", + "Description": "Enable server side encryption powered by KMS.", + "Default": "true", + "AllowedValues": [ + "true", + "false" + ] + }, + "AppSyncApiName": { + "Type": "String", + "Description": "The name of the AppSync API", + "Default": "AppSyncSimpleTransform" + }, + "APIKeyExpirationEpoch": { + "Type": "Number", + "Description": "The epoch time in seconds when the API Key should expire. Setting this to 0 will default to 7 days from the deployment date. Setting this to -1 will not create an API Key.", + "Default": 0, + "MinValue": -1 + }, + "CreateAPIKey": { + "Type": "Number", + "Description": "The boolean value to control if an API Key will be created or not. The value of the property is automatically set by the CLI. If the value is set to 0 no API Key will be created.", + "Default": 0, + "MinValue": 0, + "MaxValue": 1 + }, + "AuthCognitoUserPoolId": { + "Type": "String", + "Description": "The id of an existing User Pool to connect. If this is changed, a user pool will not be created for you.", + "Default": "NONE" + }, + "env": { + "Type": "String", + "Description": "The environment name. e.g. Dev, Test, or Production", + "Default": "NONE" + }, + "S3DeploymentBucket": { + "Type": "String", + "Description": "The S3 bucket containing all deployment assets for the project." + }, + "S3DeploymentRootKey": { + "Type": "String", + "Description": "An S3 key relative to the S3DeploymentBucket that points to the root of the deployment directory." + } + }, + "Resources": { + "DataStore": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Fn::Join": [ + "-", + [ + "AmplifyDataStore", + { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + } + ] + ] + } + ] + }, + "AttributeDefinitions": [ + { + "AttributeName": "ds_pk", + "AttributeType": "S" + }, + { + "AttributeName": "ds_sk", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "ds_pk", + "KeyType": "HASH" + }, + { + "AttributeName": "ds_sk", + "KeyType": "RANGE" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "TimeToLiveSpecification": { + "AttributeName": "_ttl", + "Enabled": true + } + } + }, + "GraphQLAPI": { + "Type": "AWS::AppSync::GraphQLApi", + "Properties": { + "Name": { + "Fn::If": [ + "HasEnvironmentParameter", + { + "Fn::Join": [ + "-", + [ + { + "Ref": "AppSyncApiName" + }, + { + "Ref": "env" + } + ] + ] + }, + { + "Ref": "AppSyncApiName" + } + ] + }, + "AuthenticationType": "AMAZON_COGNITO_USER_POOLS", + "UserPoolConfig": { + "UserPoolId": { + "Ref": "AuthCognitoUserPoolId" + }, + "AwsRegion": { + "Ref": "AWS::Region" + }, + "DefaultAction": "ALLOW" + } + } + }, + "GraphQLAPIKey": { + "Type": "AWS::AppSync::ApiKey", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "Expires": { + "Fn::If": [ + "APIKeyExpirationEpochIsPositive", + { + "Ref": "APIKeyExpirationEpoch" + }, + 1625095600 + ] + } + }, + "Condition": "ShouldCreateAPIKey" + }, + "GraphQLSchema": { + "Type": "AWS::AppSync::GraphQLSchema", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "DefinitionS3Location": { + "Fn::Sub": [ + "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/schema.graphql", + { + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + } + } + ] + } + } + }, + "Todo": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "AppSyncApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "DynamoDBModelTableReadIOPS": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "DynamoDBModelTableWriteIOPS": { + "Ref": "DynamoDBModelTableWriteIOPS" + }, + "DynamoDBBillingMode": { + "Ref": "DynamoDBBillingMode" + }, + "DynamoDBEnablePointInTimeRecovery": { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "DynamoDBEnableServerSideEncryption": { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "AppSyncApiName": { + "Ref": "AppSyncApiName" + }, + "APIKeyExpirationEpoch": { + "Ref": "APIKeyExpirationEpoch" + }, + "CreateAPIKey": { + "Ref": "CreateAPIKey" + }, + "AuthCognitoUserPoolId": { + "Ref": "AuthCognitoUserPoolId" + }, + "env": { + "Ref": "env" + }, + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "GetAttGraphQLAPIApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + } + }, + "TemplateURL": { + "Fn::Join": [ + "/", + [ + "https://s3.amazonaws.com", + { + "Ref": "S3DeploymentBucket" + }, + { + "Ref": "S3DeploymentRootKey" + }, + "stacks", + "Todo.json" + ] + ] + } + }, + "DependsOn": [ + "GraphQLSchema" + ] + }, + "Todo1": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "AppSyncApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "DynamoDBModelTableReadIOPS": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "DynamoDBModelTableWriteIOPS": { + "Ref": "DynamoDBModelTableWriteIOPS" + }, + "DynamoDBBillingMode": { + "Ref": "DynamoDBBillingMode" + }, + "DynamoDBEnablePointInTimeRecovery": { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "DynamoDBEnableServerSideEncryption": { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "AppSyncApiName": { + "Ref": "AppSyncApiName" + }, + "APIKeyExpirationEpoch": { + "Ref": "APIKeyExpirationEpoch" + }, + "CreateAPIKey": { + "Ref": "CreateAPIKey" + }, + "AuthCognitoUserPoolId": { + "Ref": "AuthCognitoUserPoolId" + }, + "env": { + "Ref": "env" + }, + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "GetAttGraphQLAPIApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + } + }, + "TemplateURL": { + "Fn::Join": [ + "/", + [ + "https://s3.amazonaws.com", + { + "Ref": "S3DeploymentBucket" + }, + { + "Ref": "S3DeploymentRootKey" + }, + "stacks", + "Todo1.json" + ] + ] + } + }, + "DependsOn": [ + "GraphQLSchema" + ] + }, + "Todo2": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "AppSyncApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "DynamoDBModelTableReadIOPS": { + "Ref": "DynamoDBModelTableReadIOPS" + }, + "DynamoDBModelTableWriteIOPS": { + "Ref": "DynamoDBModelTableWriteIOPS" + }, + "DynamoDBBillingMode": { + "Ref": "DynamoDBBillingMode" + }, + "DynamoDBEnablePointInTimeRecovery": { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "DynamoDBEnableServerSideEncryption": { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "AppSyncApiName": { + "Ref": "AppSyncApiName" + }, + "APIKeyExpirationEpoch": { + "Ref": "APIKeyExpirationEpoch" + }, + "CreateAPIKey": { + "Ref": "CreateAPIKey" + }, + "AuthCognitoUserPoolId": { + "Ref": "AuthCognitoUserPoolId" + }, + "env": { + "Ref": "env" + }, + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + }, + "GetAttGraphQLAPIApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + } + }, + "TemplateURL": { + "Fn::Join": [ + "/", + [ + "https://s3.amazonaws.com", + { + "Ref": "S3DeploymentBucket" + }, + { + "Ref": "S3DeploymentRootKey" + }, + "stacks", + "Todo2.json" + ] + ] + } + }, + "DependsOn": [ + "GraphQLSchema" + ] + }, + "CustomResourcesjson": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "AppSyncApiId": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "AppSyncApiName": { + "Ref": "AppSyncApiName" + }, + "env": { + "Ref": "env" + }, + "S3DeploymentBucket": { + "Ref": "S3DeploymentBucket" + }, + "S3DeploymentRootKey": { + "Ref": "S3DeploymentRootKey" + } + }, + "TemplateURL": { + "Fn::Join": [ + "/", + [ + "https://s3.amazonaws.com", + { + "Ref": "S3DeploymentBucket" + }, + { + "Ref": "S3DeploymentRootKey" + }, + "stacks", + "CustomResources.json" + ] + ] + } + }, + "DependsOn": [ + "GraphQLAPI", + "GraphQLSchema", + "Todo", + "Todo1", + "Todo2" + ] + } + }, + "Outputs": { + "GraphQLAPIIdOutput": { + "Description": "Your GraphQL API ID.", + "Value": { + "Fn::GetAtt": [ + "GraphQLAPI", + "ApiId" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AWS::StackName" + }, + "GraphQLApiId" + ] + ] + } + } + }, + "GraphQLAPIEndpointOutput": { + "Description": "Your GraphQL API endpoint.", + "Value": { + "Fn::GetAtt": [ + "GraphQLAPI", + "GraphQLUrl" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AWS::StackName" + }, + "GraphQLApiEndpoint" + ] + ] + } + } + }, + "GraphQLAPIKeyOutput": { + "Description": "Your GraphQL API key. Provide via 'x-api-key' header.", + "Value": { + "Fn::GetAtt": [ + "GraphQLAPIKey", + "ApiKey" + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + ":", + [ + { + "Ref": "AWS::StackName" + }, + "GraphQLApiKey" + ] + ] + } + }, + "Condition": "ShouldCreateAPIKey" + } + }, + "Mappings": {}, + "Conditions": { + "ShouldUsePayPerRequestBilling": { + "Fn::Equals": [ + { + "Ref": "DynamoDBBillingMode" + }, + "PAY_PER_REQUEST" + ] + }, + "ShouldUsePointInTimeRecovery": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnablePointInTimeRecovery" + }, + "true" + ] + }, + "ShouldUseServerSideEncryption": { + "Fn::Equals": [ + { + "Ref": "DynamoDBEnableServerSideEncryption" + }, + "true" + ] + }, + "ShouldCreateAPIKey": { + "Fn::Equals": [ + { + "Ref": "CreateAPIKey" + }, + 1 + ] + }, + "APIKeyExpirationEpochIsPositive": { + "Fn::And": [ + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + -1 + ] + } + ] + }, + { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "APIKeyExpirationEpoch" + }, + 0 + ] + } + ] + } + ] + }, + "HasEnvironmentParameter": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "env" + }, + "NONE" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/parameters.json b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/parameters.json new file mode 100644 index 00000000000..f36e7f55c15 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/testData/mockLocalCloud/parameters.json @@ -0,0 +1,14 @@ +{ + "CreateAPIKey": 0, + "AppSyncApiName": "lil", + "DynamoDBBillingMode": "PAY_PER_REQUEST", + "DynamoDBEnableServerSideEncryption": false, + "AuthCognitoUserPoolId": { + "Fn::GetAtt": [ + "authlil14996494", + "Outputs.UserPoolId" + ] + }, + "S3DeploymentBucket": "amplify-lil-dev-182009-deployment", + "S3DeploymentRootKey": "amplify-appsync-files/ca354db288aaaaa5748d16efed07066153c0960d" +} \ No newline at end of file diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts index f12651636c3..72abd96d143 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status-diff.ts @@ -13,13 +13,13 @@ const CategoryProviders = { CLOUDFORMATION : "cloudformation", } -interface StackMutationInfo { +export interface StackMutationInfo { label : String; consoleStyle : chalk.Chalk; icon : String; } //helper for summary styling -interface StackMutationType { +export interface StackMutationType { CREATE : StackMutationInfo, UPDATE : StackMutationInfo, DELETE : StackMutationInfo, @@ -65,7 +65,7 @@ export const stackMutationType : StackMutationType = { } //helper to capitalize string -export function capitalize(str) { +export function capitalize(str: string) : string { return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); } @@ -110,7 +110,7 @@ export class ResourceDiff { cloudTemplate : Template; mutationInfo : StackMutationInfo; - constructor( category, resourceName, provider, mutationInfo ){ + constructor( category: string, resourceName : string, provider : string, mutationInfo: StackMutationInfo ){ this.localBackendDir = pathManager.getBackendDirPath(); this.cloudBackendDir = pathManager.getCurrentCloudBackendDirPath(); this.resourceName = resourceName; @@ -149,7 +149,7 @@ export class ResourceDiff { const resourceTemplatePaths = await this.getCfnResourceFilePaths(); //set the member template objects this.localTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.localTemplatePath); - this.cloudTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.cloudTemplatePath); + this.cloudTemplate = await this.safeReadCFNTemplate(resourceTemplatePaths.cloudTemplatePath); //Note!! :- special handling to support multi-env. Currently in multi-env, when new env is created, //we do *Not* delete the cloud-backend folder. Hence this logic will always give no diff for new resources. @@ -163,9 +163,8 @@ export class ResourceDiff { return diff; } - - //helper: wrapper around readCFNTemplate type to handle expressions. - private safeReadCFNTemplate = async(filePath : string ) => { + //helper: wrapper around readCFNTemplate type to handle expressions. + private safeReadCFNTemplate = async(filePath : string ) => { try { const templateResult = await readCFNTemplate(filePath); return templateResult.cfnTemplate;