From a509b8eeb057be51f303b47e4e5618b4985bfc9d Mon Sep 17 00:00:00 2001 From: akshbhu <39866697+akshbhu@users.noreply.github.com> Date: Thu, 29 Jul 2021 15:10:23 -0700 Subject: [PATCH] feat: Override functionality enabled for Root stack (#7702) * feat: initial commit * feat: root stack creation on init with cdk * feat: enable for push * feat: added build command for root stack override * fix: authrole and Unauthrole Names * feat: added synthesizer and address comments * feat: added hash for root stack * feat: deploy root stack to disk * feat: updated amplifyMeta after root override * feat: update curren-cloud-backend * feat: refractor Root transform and added unit tests * fix: removes Template from Cloudform * feat: added build step in overrides --- .../src/state-manager/pathManager.ts | 43 +++ packages/amplify-cli/amplify-plugin.json | 1 + .../src/commands/build-overrides.ts | 27 ++ .../amplify-helpers/push-resources.ts | 2 + .../amplify-helpers/resource-status.ts | 41 ++- .../root-stack-builder.test.ts.snap | 161 +++++++++ .../root-stack-transform.test.ts.snap | 322 ++++++++++++++++++ .../root-stack-builder/overrides/override.js | 14 + .../root-stack-builder.test.ts | 200 +++++++++++ .../root-stack-transform.test.ts | 48 +++ .../src/index.ts | 3 + .../src/initializer.ts | 40 ++- .../src/overrideManager.ts | 61 ++++ .../src/push-resources.ts | 75 ++-- .../src/root-stack-builder/index.ts | 2 + .../root-stack-builder/root-stack-builder.ts | 190 +++++++++++ .../root-stack-transform.ts | 264 ++++++++++++++ .../root-stack-builder/stackSynthesizer.ts | 45 +++ .../src/root-stack-builder/types.ts | 31 ++ .../src/utility-functions.js | 7 + .../src/utils/override-skeleton-generator.ts | 4 +- 21 files changed, 1546 insertions(+), 35 deletions(-) create mode 100644 packages/amplify-cli/src/commands/build-overrides.ts create mode 100644 packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-builder.test.ts.snap create mode 100644 packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-transform.test.ts.snap create mode 100644 packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/overrides/override.js create mode 100644 packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-builder.test.ts create mode 100644 packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-transform.test.ts create mode 100644 packages/amplify-provider-awscloudformation/src/overrideManager.ts create mode 100644 packages/amplify-provider-awscloudformation/src/root-stack-builder/index.ts create mode 100644 packages/amplify-provider-awscloudformation/src/root-stack-builder/root-stack-builder.ts create mode 100644 packages/amplify-provider-awscloudformation/src/root-stack-builder/root-stack-transform.ts create mode 100644 packages/amplify-provider-awscloudformation/src/root-stack-builder/stackSynthesizer.ts create mode 100644 packages/amplify-provider-awscloudformation/src/root-stack-builder/types.ts diff --git a/packages/amplify-cli-core/src/state-manager/pathManager.ts b/packages/amplify-cli-core/src/state-manager/pathManager.ts index d7c0425fd38..f310104cab3 100644 --- a/packages/amplify-cli-core/src/state-manager/pathManager.ts +++ b/packages/amplify-cli-core/src/state-manager/pathManager.ts @@ -20,6 +20,11 @@ export const PathConstants = { BackendDirName: 'backend', CurrentCloudBackendDirName: '#current-cloud-backend', + // 2nd Level + OverrideDirName: 'overrides', + ProviderName: 'awscloudformation', + CfnStacksBuildDirName: 'build', + // FileNames AmplifyAdminConfigFileName: 'config.json', @@ -35,6 +40,7 @@ export const PathConstants = { LocalAWSInfoFileName: 'local-aws-info.json', TeamProviderInfoFileName: 'team-provider-info.json', BackendConfigFileName: 'backend-config.json', + OverrideFileName: 'override.ts', CLIJSONFileName: 'cli.json', CLIJSONFileNameGlob: 'cli*.json', @@ -77,6 +83,43 @@ export class PathManager { getBackendDirPath = (projectPath?: string): string => this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.BackendDirName]); + getOverrideDirPath = (projectPath: string, category: string, resourceName: string): string => { + return this.constructPath(projectPath, [ + PathConstants.AmplifyDirName, + PathConstants.BackendDirName, + category!, + resourceName!, + PathConstants.OverrideDirName, + ]); + }; + + getRootOverrideDirPath = (projectPath: string): string => { + return this.constructPath(projectPath, [ + PathConstants.AmplifyDirName, + PathConstants.BackendDirName, + PathConstants.ProviderName, + PathConstants.OverrideDirName, + ]); + }; + + getRootStackDirPath = (projectPath: string): string => { + return this.constructPath(projectPath, [ + PathConstants.AmplifyDirName, + PathConstants.BackendDirName, + PathConstants.ProviderName, + PathConstants.CfnStacksBuildDirName, + ]); + }; + + getCurrentCloudRootStackDirPath = (projectPath: string): string => { + return this.constructPath(projectPath, [ + PathConstants.AmplifyDirName, + PathConstants.CurrentCloudBackendDirName, + PathConstants.ProviderName, + PathConstants.CfnStacksBuildDirName, + ]); + }; + getCurrentCloudBackendDirPath = (projectPath?: string): string => this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.CurrentCloudBackendDirName]); diff --git a/packages/amplify-cli/amplify-plugin.json b/packages/amplify-cli/amplify-plugin.json index 2849e512d89..e51b3b77fa6 100644 --- a/packages/amplify-cli/amplify-plugin.json +++ b/packages/amplify-cli/amplify-plugin.json @@ -2,6 +2,7 @@ "name": "core", "type": "core", "commands": [ + "build-overrides", "categories", "configure", "console", diff --git a/packages/amplify-cli/src/commands/build-overrides.ts b/packages/amplify-cli/src/commands/build-overrides.ts new file mode 100644 index 00000000000..9be78a5cdcb --- /dev/null +++ b/packages/amplify-cli/src/commands/build-overrides.ts @@ -0,0 +1,27 @@ +/** + * Command to transform CFN with overrides + */ +const subcommand = 'build-overrides'; + +module.exports = { + name: subcommand, + run: async context => { + try { + const { + parameters: { options }, + } = context; + await context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', { + forceCompile: true, + }); + } catch (error) { + context.print.error(error.message); + + if (error.stack) { + context.print.info(error.stack); + } + + context.usageData.emitError(error); + process.exitCode = 1; + } + }, +}; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts b/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts index 4e71549cc77..97f9407b9f4 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts @@ -49,6 +49,8 @@ export async function pushResources( } } + // building all CFN stacks here to get the resource Changes + context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', { forceCompile: true }); const hasChanges = await showResourceTable(category, resourceName, filteredResources); // no changes detected 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..78e37f4c6a8 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/resource-status.ts @@ -9,6 +9,7 @@ import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './ 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 { rootStackFileName } from 'amplify-provider-awscloudformation'; async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPushTimeStamp, hashFunction) { // Pushing the resource for the first time hence no lastPushTimeStamp @@ -29,6 +30,17 @@ async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPus return localDirHash !== cloudDirHash; } +export function getHashForRootStack(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); +} + export function getHashForResourceDir(dirPath, files?: string[]) { const options: HashElementOptions = { folders: { exclude: ['.*', 'node_modules', 'test_coverage', 'dist', 'build'] }, @@ -388,6 +400,10 @@ export async function getResourceStatus(category?, resourceName?, providerName?, // if not equal there is a tag update const tagsUpdated = !_.isEqual(stateManager.getProjectTags(), stateManager.getCurrentProjectTags()); + // check if there is an update in root stack + + const rootStackUpdated: boolean = await isRootStackModifiedSinceLastPush(getHashForRootStack); + return { resourcesToBeCreated, resourcesToBeUpdated, @@ -395,6 +411,7 @@ export async function getResourceStatus(category?, resourceName?, providerName?, resourcesToBeDeleted, tagsUpdated, allResources, + rootStackUpdated, }; } @@ -416,6 +433,7 @@ export async function showResourceTable(category, resourceName, filteredResource resourcesToBeSynced, allResources, tagsUpdated, + rootStackUpdated, } = await getResourceStatus(category, resourceName, undefined, filteredResources); let noChangeResources = _.differenceWith( @@ -501,8 +519,29 @@ export async function showResourceTable(category, resourceName, filteredResource print.info('\nTag Changes Detected'); } + if (rootStackUpdated) { + print.info('\n RootStack Changes Detected'); + } + const resourceChanged = - resourcesToBeCreated.length + resourcesToBeUpdated.length + resourcesToBeSynced.length + resourcesToBeDeleted.length > 0 || tagsUpdated; + resourcesToBeCreated.length + resourcesToBeUpdated.length + resourcesToBeSynced.length + resourcesToBeDeleted.length > 0 || + tagsUpdated || + rootStackUpdated; return resourceChanged; } + +async function isRootStackModifiedSinceLastPush(hashFunction): Promise { + try { + const projectPath = pathManager.findProjectRoot(); + const localBackendDir = pathManager.getRootStackDirPath(projectPath!); + const cloudBackendDir = pathManager.getCurrentCloudRootStackDirPath(projectPath!); + + const localDirHash = await hashFunction(localBackendDir, [rootStackFileName]); + const cloudDirHash = await hashFunction(cloudBackendDir, [rootStackFileName]); + + return localDirHash !== cloudDirHash; + } catch (error) { + throw new Error('Amplify Project not initialized.'); + } +} diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-builder.test.ts.snap b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-builder.test.ts.snap new file mode 100644 index 00000000000..c50f3f58fff --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-builder.test.ts.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Check RootStack Template generates root stack Template 1`] = ` +Object { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Root Stack for AWS Amplify CLI", + "Outputs": Object { + "AuthRoleArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "AuthRole", + "Arn", + ], + }, + }, + "Region": Object { + "Description": "CloudFormation provider root stack Region", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-Region", + }, + }, + "Value": Object { + "Ref": "AWS::Region", + }, + }, + "StackId": Object { + "Description": "CloudFormation provider root stack name", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-StackId", + }, + }, + "Value": Object { + "Ref": "AWS::StackId", + }, + }, + "StackName": Object { + "Description": "CloudFormation provider root stack ID", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-StackName", + }, + }, + "Value": Object { + "Ref": "AWS::StackName", + }, + }, + "UnAuthRoleArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "UnauthRole", + "Arn", + ], + }, + }, + }, + "Parameters": Object { + "AuthRoleName": Object { + "Default": "AuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "DeploymentBucketName": Object { + "Default": "DeploymentBucket", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "UnauthRoleName": Object { + "Default": "UnAuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + }, + "Resources": Object { + "AuthRole": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRoleWithWebIdentity", + "Effect": "Deny", + "Principal": Object { + "Federated": "cognito-identity.amazonaws.com", + }, + "Sid": "", + }, + ], + "Version": "2012-10-17", + }, + "RoleName": Object { + "Ref": "AuthRoleName", + }, + }, + "Type": "AWS::IAM::Role", + }, + "DeploymentBucket": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "BucketName": Object { + "Ref": "DeploymentBucketName", + }, + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + "UnauthRole": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRoleWithWebIdentity", + "Effect": "Deny", + "Principal": Object { + "Federated": "cognito-identity.amazonaws.com", + }, + "Sid": "", + }, + ], + "Version": "2012-10-17", + }, + "RoleName": Object { + "Ref": "UnauthRoleName", + }, + }, + "Type": "AWS::IAM::Role", + }, + }, +} +`; + +exports[`Check RootStack Template rootstack template generated by constructor 1`] = ` +Object { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Root Stack for AWS Amplify CLI", +} +`; + +exports[`Check RootStack Template rootstack template generated by constructor with some parameters 1`] = ` +Object { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Root Stack for AWS Amplify CLI", + "Parameters": Object { + "AuthRoleName": Object { + "Default": "AuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "DeploymentBucketName": Object { + "Default": "DeploymentBucket", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "UnauthRoleName": Object { + "Default": "UnAuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + }, +} +`; diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-transform.test.ts.snap b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-transform.test.ts.snap new file mode 100644 index 00000000000..91d28d6105e --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/__snapshots__/root-stack-transform.test.ts.snap @@ -0,0 +1,322 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Check RootStack Template Generated rootstack template during Push with overrides 1`] = ` +Object { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Root Stack for AWS Amplify CLI", + "Outputs": Object { + "AuthRoleArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "AuthRole", + "Arn", + ], + }, + }, + "AuthRoleName": Object { + "Value": Object { + "Ref": "AuthRoleName", + }, + }, + "DeploymentBucketName": Object { + "Description": "CloudFormation provider root stack deployment bucket name", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-DeploymentBucketName", + }, + }, + "Value": Object { + "Ref": "DeploymentBucketName", + }, + }, + "Region": Object { + "Description": "CloudFormation provider root stack Region", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-Region", + }, + }, + "Value": Object { + "Ref": "AWS::Region", + }, + }, + "StackId": Object { + "Description": "CloudFormation provider root stack name", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-StackId", + }, + }, + "Value": Object { + "Ref": "AWS::StackId", + }, + }, + "StackName": Object { + "Description": "CloudFormation provider root stack ID", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-StackName", + }, + }, + "Value": Object { + "Ref": "AWS::StackName", + }, + }, + "UnAuthRoleArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "UnauthRole", + "Arn", + ], + }, + }, + "UnauthRoleName": Object { + "Value": Object { + "Ref": "UnauthRoleName", + }, + }, + }, + "Parameters": Object { + "AuthRoleName": Object { + "Default": "AuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "DeploymentBucketName": Object { + "Default": "DeploymentBucket", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "UnauthRoleName": Object { + "Default": "UnAuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "random": Object { + "Default": "cjsncksdncks", + "Description": "nkjdsncksndc", + "Type": "String", + }, + }, + "Resources": Object { + "AuthRole": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRoleWithWebIdentity", + "Effect": "Deny", + "Principal": Object { + "Federated": "cognito-identity.amazonaws.com", + }, + "Sid": "", + }, + ], + "Version": "2012-10-17", + }, + "RoleName": "randomrole", + }, + "Type": "AWS::IAM::Role", + }, + "DeploymentBucket": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "BucketEncryption": BucketEncryption { + "ServerSideEncryptionConfiguration": Array [ + ServerSideEncryptionRule { + "ServerSideEncryptionByDefault": ServerSideEncryptionByDefault { + "SSEAlgorithm": "AES256", + }, + }, + ], + }, + "BucketName": Object { + "Ref": "DeploymentBucketName", + }, + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + "UnauthRole": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRoleWithWebIdentity", + "Effect": "Deny", + "Principal": Object { + "Federated": "cognito-identity.amazonaws.com", + }, + "Sid": "", + }, + ], + "Version": "2012-10-17", + }, + "RoleName": Object { + "Ref": "UnauthRoleName", + }, + }, + "Type": "AWS::IAM::Role", + }, + }, +} +`; + +exports[`Check RootStack Template Generated rootstack template during init 1`] = ` +Object { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Root Stack for AWS Amplify CLI", + "Outputs": Object { + "AuthRoleArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "AuthRole", + "Arn", + ], + }, + }, + "AuthRoleName": Object { + "Value": Object { + "Ref": "AuthRoleName", + }, + }, + "DeploymentBucketName": Object { + "Description": "CloudFormation provider root stack deployment bucket name", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-DeploymentBucketName", + }, + }, + "Value": Object { + "Ref": "DeploymentBucketName", + }, + }, + "Region": Object { + "Description": "CloudFormation provider root stack Region", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-Region", + }, + }, + "Value": Object { + "Ref": "AWS::Region", + }, + }, + "StackId": Object { + "Description": "CloudFormation provider root stack name", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-StackId", + }, + }, + "Value": Object { + "Ref": "AWS::StackId", + }, + }, + "StackName": Object { + "Description": "CloudFormation provider root stack ID", + "Export": Object { + "Name": Object { + "Fn::Sub": "\${AWS::StackName}-StackName", + }, + }, + "Value": Object { + "Ref": "AWS::StackName", + }, + }, + "UnAuthRoleArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "UnauthRole", + "Arn", + ], + }, + }, + "UnauthRoleName": Object { + "Value": Object { + "Ref": "UnauthRoleName", + }, + }, + }, + "Parameters": Object { + "AuthRoleName": Object { + "Default": "AuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "DeploymentBucketName": Object { + "Default": "DeploymentBucket", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + "UnauthRoleName": Object { + "Default": "UnAuthRoleName", + "Description": "Name of the common deployment bucket provided by the parent stack", + "Type": "String", + }, + }, + "Resources": Object { + "AuthRole": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRoleWithWebIdentity", + "Effect": "Deny", + "Principal": Object { + "Federated": "cognito-identity.amazonaws.com", + }, + "Sid": "", + }, + ], + "Version": "2012-10-17", + }, + "RoleName": Object { + "Ref": "AuthRoleName", + }, + }, + "Type": "AWS::IAM::Role", + }, + "DeploymentBucket": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "BucketEncryption": BucketEncryption { + "ServerSideEncryptionConfiguration": Array [ + ServerSideEncryptionRule { + "ServerSideEncryptionByDefault": ServerSideEncryptionByDefault { + "SSEAlgorithm": "AES256", + }, + }, + ], + }, + "BucketName": Object { + "Ref": "DeploymentBucketName", + }, + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + "UnauthRole": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRoleWithWebIdentity", + "Effect": "Deny", + "Principal": Object { + "Federated": "cognito-identity.amazonaws.com", + }, + "Sid": "", + }, + ], + "Version": "2012-10-17", + }, + "RoleName": Object { + "Ref": "UnauthRoleName", + }, + }, + "Type": "AWS::IAM::Role", + }, + }, +} +`; diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/overrides/override.js b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/overrides/override.js new file mode 100644 index 00000000000..5e79253de19 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/overrides/override.js @@ -0,0 +1,14 @@ +function overrideProps(props) { + props.authRole.roleName = 'randomrole'; + props.addCfnParameter( + { + type: 'String', + description: 'nkjdsncksndc', + default: 'cjsncksdncks', + }, + 'random', + ); + return props; +} + +module.exports = { overrideProps }; diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-builder.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-builder.test.ts new file mode 100644 index 00000000000..1cce2084fc7 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-builder.test.ts @@ -0,0 +1,200 @@ +import { SynthUtils } from '@aws-cdk/assert'; +import * as cdk from '@aws-cdk/core'; +import { AmplifyRootStack } from '../../root-stack-builder/root-stack-builder'; + +jest.mock('../../root-stack-builder/stackSynthesizer'); + +describe('Check RootStack Template', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('rootstack template generated by constructor', () => { + const app = new cdk.App(); + let mocksynth: cdk.IStackSynthesizer; + const stack = new AmplifyRootStack(app, 'rootStack', { synthesizer: mocksynth }); + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + }); + + test('rootstack template generated by constructor with some parameters', () => { + const app = new cdk.App(); + let mocksynth: cdk.IStackSynthesizer; + const stack = new AmplifyRootStack(app, 'rootStack', { synthesizer: mocksynth }); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'DeploymentBucket', + }, + 'DeploymentBucketName', + ); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'AuthRoleName', + }, + 'AuthRoleName', + ); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'UnAuthRoleName', + }, + 'UnauthRoleName', + ); + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + }); + + test('adding same logicalId parameter should throw error', () => { + const app = new cdk.App(); + let mocksynth: cdk.IStackSynthesizer; + const stack = new AmplifyRootStack(app, 'rootStack', { synthesizer: mocksynth }); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'AuthRoleName', + }, + 'AuthRoleName', + ); + + expect(() => + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'AuthRoleName', + }, + 'AuthRoleName', + ), + ).toThrow('logical Id already Exists'); + }); + + test(' adding two resources with same logicalId throw error', () => { + const app = new cdk.App(); + let mocksynth: cdk.IStackSynthesizer; + const stack = new AmplifyRootStack(app, 'rootStack', { synthesizer: mocksynth }); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'DeploymentBucket', + }, + 'DeploymentBucketName', + ); + + // Add Outputs + stack.addCfnOutput( + { + description: 'CloudFormation provider root stack Region', + value: cdk.Fn.ref('AWS::Region'), + exportName: cdk.Fn.sub('${AWS::StackName}-Region'), + }, + 'Region', + ); + + stack.addCfnOutput( + { + description: 'CloudFormation provider root stack ID', + value: cdk.Fn.ref('AWS::StackName'), + exportName: cdk.Fn.sub('${AWS::StackName}-StackName'), + }, + 'StackName', + ); + + expect(() => + stack.addCfnOutput( + { + description: 'CloudFormation provider root stack deployment bucket name', + value: cdk.Fn.ref('DeploymentBucketName'), + exportName: cdk.Fn.sub('${AWS::StackName}-DeploymentBucketName'), + }, + 'DeploymentBucketName', + ), + ).toThrow(`There is already a Construct with name 'DeploymentBucketName' in AmplifyRootStack`); + }); + + test('generates root stack Template', async () => { + const app = new cdk.App(); + let mocksynth: cdk.IStackSynthesizer; + const stack = new AmplifyRootStack(app, 'rootStack', { synthesizer: mocksynth }); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'DeploymentBucket', + }, + 'DeploymentBucketName', + ); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'AuthRoleName', + }, + 'AuthRoleName', + ); + + stack.addCfnParameter( + { + type: 'String', + description: 'Name of the common deployment bucket provided by the parent stack', + default: 'UnAuthRoleName', + }, + 'UnauthRoleName', + ); + + // Add Outputs + stack.addCfnOutput( + { + description: 'CloudFormation provider root stack Region', + value: cdk.Fn.ref('AWS::Region'), + exportName: cdk.Fn.sub('${AWS::StackName}-Region'), + }, + 'Region', + ); + + stack.addCfnOutput( + { + description: 'CloudFormation provider root stack ID', + value: cdk.Fn.ref('AWS::StackName'), + exportName: cdk.Fn.sub('${AWS::StackName}-StackName'), + }, + 'StackName', + ); + + stack.addCfnOutput( + { + description: 'CloudFormation provider root stack name', + value: cdk.Fn.ref('AWS::StackId'), + exportName: cdk.Fn.sub('${AWS::StackName}-StackId'), + }, + 'StackId', + ); + + stack.addCfnOutput( + { + value: cdk.Fn.getAtt('AuthRole', 'Arn').toString(), + }, + 'AuthRoleArn', + ); + + stack.addCfnOutput( + { + value: cdk.Fn.getAtt('UnauthRole', 'Arn').toString(), + }, + 'UnAuthRoleArn', + ); + + await stack.generateRootStackResources(); + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-transform.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-transform.test.ts new file mode 100644 index 00000000000..d05bd2e668b --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/root-stack-builder/root-stack-transform.test.ts @@ -0,0 +1,48 @@ +import { AmplifyRootStackTransform, CommandType, RootStackTransformOptions } from '../../root-stack-builder/root-stack-transform'; +import * as path from 'path'; +import { prePushCfnTemplateModifier } from '../../pre-push-cfn-processor/pre-push-cfn-modifier'; + +jest.mock('amplify-cli-core'); + +jest.mock('../../utils/override-skeleton-generator'); + +describe('Check RootStack Template', () => { + it('Generated rootstack template during init', async () => { + // CFN transform for Root stack + const rootStackFileName = 'template.json'; + const props: RootStackTransformOptions = { + resourceConfig: { + stackFileName: rootStackFileName, + }, + cfnModifiers: prePushCfnTemplateModifier, + }; + const rootTransform = new AmplifyRootStackTransform(props, CommandType.INIT); + const mock_template = await rootTransform.transform(); + expect(mock_template).toMatchSnapshot(); + }); + + it('Generated rootstack template during Push with overrides', async () => { + // CFN transform for Root stack + const rootStackFileName = 'template.json'; + const rootFilePath = 'randomPath'; + const overridePath = path.join(__dirname, 'overrides', 'override.js'); + const overrideDir = path.join(__dirname, 'overrides'); + + const props: RootStackTransformOptions = { + resourceConfig: { + stackFileName: rootStackFileName, + }, + deploymentOptions: { + rootFilePath, + }, + overrideOptions: { + overrideFnPath: overridePath, + overrideDir, + }, + cfnModifiers: prePushCfnTemplateModifier, + }; + const rootTransform = new AmplifyRootStackTransform(props, CommandType.PUSH); + const mock_template = await rootTransform.transform(); + expect(mock_template).toMatchSnapshot(); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/index.ts b/packages/amplify-provider-awscloudformation/src/index.ts index 0602d926a78..6a9cf939f9e 100644 --- a/packages/amplify-provider-awscloudformation/src/index.ts +++ b/packages/amplify-provider-awscloudformation/src/index.ts @@ -33,6 +33,8 @@ import { $TSContext } from 'amplify-cli-core'; export { resolveAppId } from './utils/resolve-appId'; export { loadConfigurationForEnv } from './configuration-manager'; import { updateEnv } from './update-env'; +export const rootStackFileName = 'root-cloudformation-stack.json'; +export { storeRootStackTemplate } from './initializer'; function init(context) { return initializer.run(context); @@ -156,4 +158,5 @@ module.exports = { loadConfigurationForEnv, getConfiguredSSMClient, updateEnv, + rootStackFileName, }; diff --git a/packages/amplify-provider-awscloudformation/src/initializer.ts b/packages/amplify-provider-awscloudformation/src/initializer.ts index 03ddb87225a..0be6c46053d 100644 --- a/packages/amplify-provider-awscloudformation/src/initializer.ts +++ b/packages/amplify-provider-awscloudformation/src/initializer.ts @@ -1,9 +1,13 @@ import { $TSContext } from 'amplify-cli-core'; import _ from 'lodash'; +import { CommandType } from './root-stack-builder'; +import { rootStackFileName } from '.'; +import { pathManager, PathConstants, stateManager, JSONUtilities } from 'amplify-cli-core'; +import { Template } from './root-stack-builder/types'; +import { transformRootStack } from './overrideManager'; const moment = require('moment'); const path = require('path'); -const { pathManager, PathConstants, stateManager, JSONUtilities } = require('amplify-cli-core'); const glob = require('glob'); const archiver = require('./utils/archiver'); const fs = require('fs-extra'); @@ -25,14 +29,10 @@ export async function run(context) { if (!context.exeInfo || context.exeInfo.isNewEnv) { context.exeInfo = context.exeInfo || {}; const { projectName } = context.exeInfo.projectConfig; - const initTemplateFilePath = path.join(__dirname, '..', 'resources', 'rootStackTemplate.json'); const timeStamp = `${moment().format('Hmmss')}`; const { envName = '' } = context.exeInfo.localEnvInfo; let stackName = normalizeStackName(`amplify-${projectName}-${envName}-${timeStamp}`); const awsConfig = await configurationManager.getAwsConfig(context); - - await configurePermissionsBoundaryForInit(context); - const amplifyServiceParams = { context, awsConfig, @@ -41,20 +41,25 @@ export async function run(context) { stackName, }; const { amplifyAppId, verifiedStackName, deploymentBucketName } = await amplifyServiceManager.init(amplifyServiceParams); + await configurePermissionsBoundaryForInit(context); + + // start root stack builder and deploy + // moved cfn build to next its builder stackName = verifiedStackName; const Tags = context.amplify.getTags(context); const authRoleName = `${stackName}-authRole`; const unauthRoleName = `${stackName}-unauthRole`; - const rootStack = JSONUtilities.readJson(initTemplateFilePath); - await prePushCfnTemplateModifier(rootStack); + // CFN transform for Root stack + const rootStack = await transformRootStack(CommandType.INIT); // Track Amplify Console generated stacks if (!!process.env.CLI_DEV_INTERNAL_DISABLE_AMPLIFY_APP_DELETION) { rootStack.Description = 'Root Stack for AWS Amplify Console'; } + // deploy steps const params = { StackName: stackName, Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], @@ -164,12 +169,33 @@ function cloneCLIJSONForNewEnvironment(context) { export async function onInitSuccessful(context) { configurationManager.onInitSuccessful(context); if (context.exeInfo.isNewEnv) { + await storeRootStackTemplate(); context = await storeCurrentCloudBackend(context); await storeArtifactsForAmplifyService(context); } return context; } +export const storeRootStackTemplate = async (template?: Template) => { + // generate template again as the folder structure was not created when root stack was initiaized + if (template === undefined) { + template = await transformRootStack(CommandType.ON_INIT); + } + + // RootStack deployed to backend/awscloudformation/build + const projectRoot = pathManager.findProjectRoot(); + const rootStackBackendBuildDir = pathManager.getRootStackDirPath(projectRoot); + const rootStackCloudBackendBuildDir = pathManager.getCurrentCloudRootStackDirPath(projectRoot); + + fs.ensureDirSync(rootStackBackendBuildDir); + const rootStackBackendFilePath = path.join(rootStackBackendBuildDir, rootStackFileName); + const rootStackCloudBackendFilePath = path.join(rootStackCloudBackendBuildDir, rootStackFileName); + + JSONUtilities.writeJson(rootStackBackendFilePath, template); + // copy the awscloudformation backend to #current-cloud-backend + fs.copySync(rootStackBackendFilePath, rootStackCloudBackendFilePath); +}; + function storeCurrentCloudBackend(context) { const zipFilename = '#current-cloud-backend.zip'; const backendDir = context.amplify.pathManager.getBackendDirPath(); diff --git a/packages/amplify-provider-awscloudformation/src/overrideManager.ts b/packages/amplify-provider-awscloudformation/src/overrideManager.ts new file mode 100644 index 00000000000..8a383d731bc --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/overrideManager.ts @@ -0,0 +1,61 @@ +import { AmplifyRootStackTransform, CommandType, RootStackTransformOptions } from './root-stack-builder'; +import { rootStackFileName } from '.'; +import { prePushCfnTemplateModifier } from './pre-push-cfn-processor/pre-push-cfn-modifier'; +import * as path from 'path'; +import { pathManager } from 'amplify-cli-core'; +import { Template } from 'cloudform-types'; +/** + * + * @param context + * @returns + */ +export async function transformCfnWithOverrides(context): Promise