From 3db8d3d6b62f63daf64d598515f0c2c208708109 Mon Sep 17 00:00:00 2001 From: Attila Hajdrik Date: Tue, 4 May 2021 12:02:53 -0700 Subject: [PATCH] fix: #7243 yaml parsing should support all cfn functions (#7245) * fix: #7243 yaml parsing should support all cfn functions --- .../src/__tests__/cfnUtilities.test.ts | 90 +++++++++++++++++- packages/amplify-cli-core/src/cfnUtilities.ts | 95 ++++++++++++------- 2 files changed, 149 insertions(+), 36 deletions(-) diff --git a/packages/amplify-cli-core/src/__tests__/cfnUtilities.test.ts b/packages/amplify-cli-core/src/__tests__/cfnUtilities.test.ts index 82c77049e2f..1703f865a87 100644 --- a/packages/amplify-cli-core/src/__tests__/cfnUtilities.test.ts +++ b/packages/amplify-cli-core/src/__tests__/cfnUtilities.test.ts @@ -1,5 +1,5 @@ import * as fs from 'fs-extra'; -import { CFNTemplateFormat, JSONUtilities, readCFNTemplate, writeCFNTemplate } from '../../lib'; +import { CFNTemplateFormat, JSONUtilities, readCFNTemplate, writeCFNTemplate } from '../..'; jest.mock('fs-extra'); @@ -22,13 +22,16 @@ type TwoArgReadFile = (p: string, e: string) => Promise; describe('readCFNTemplate', () => { beforeEach(() => jest.clearAllMocks()); + it('throws if specified file does not exist', async () => { fs_mock.existsSync.mockReturnValueOnce(false); await expect(readCFNTemplate(testPath)).rejects.toMatchInlineSnapshot( `[Error: No CloudFormation template found at /this/is/a/test/path.json]`, ); + fs_mock.existsSync.mockReturnValueOnce(true); fs_mock.statSync.mockReturnValueOnce(({ isFile: false } as unknown) as fs.Stats); + await expect(readCFNTemplate(testPath)).rejects.toMatchInlineSnapshot( `[Error: No CloudFormation template found at /this/is/a/test/path.json]`, ); @@ -36,14 +39,18 @@ describe('readCFNTemplate', () => { it('returns template with json format', async () => { ((fs_mock.readFile as unknown) as jest.MockedFunction).mockResolvedValueOnce(jsonContent); + const result = await readCFNTemplate(testPath); + expect(result.templateFormat).toEqual(CFNTemplateFormat.JSON); expect(result.cfnTemplate).toEqual(testTemplate); }); it('returns template with yaml format', async () => { ((fs_mock.readFile as unknown) as jest.MockedFunction).mockResolvedValueOnce(yamlContent); + const result = await readCFNTemplate(testPath); + expect(result.templateFormat).toEqual(CFNTemplateFormat.YAML); expect(result.cfnTemplate).toEqual(testTemplate); }); @@ -52,9 +59,11 @@ describe('readCFNTemplate', () => { const yamlContent = ` !GetAtt myResource.output.someProp `; + ((fs_mock.readFile as unknown) as jest.MockedFunction).mockResolvedValueOnce(yamlContent); const result = await readCFNTemplate(testPath); + expect(result.cfnTemplate).toMatchInlineSnapshot(` Object { "Fn::GetAtt": Array [ @@ -68,20 +77,99 @@ describe('readCFNTemplate', () => { describe('writeCFNTemplate', () => { beforeEach(() => jest.clearAllMocks()); + it('creates destination if it does not exist', async () => { await writeCFNTemplate(testTemplate, testPath); + expect(fs_mock.ensureDir.mock.calls[0][0]).toEqual('/this/is/a/test'); }); it('writes json templates by default', async () => { await writeCFNTemplate(testTemplate, testPath); + expect(fs_mock.writeFile.mock.calls[0][0]).toEqual(testPath); expect(fs_mock.writeFile.mock.calls[0][1]).toEqual(jsonContent); }); it('writes yaml templates if specified', async () => { await writeCFNTemplate(testTemplate, testPath, { templateFormat: CFNTemplateFormat.YAML }); + expect(fs_mock.writeFile.mock.calls[0][0]).toEqual(testPath); expect(fs_mock.writeFile.mock.calls[0][1]).toEqual(yamlContent); }); }); + +describe('roundtrip CFN Templates to object and back', () => { + beforeEach(() => jest.clearAllMocks()); + + it('roundtripped yml input should result in same object', async () => { + const yamlContent = ` + Properties: + B64: !Base64 AWS CloudFormation + SimpleCidr: !Cidr [ "192.168.0.0/24", 6, 5 ] + NestedCidr: !Select [ 0, !Cidr [ !GetAtt ExampleVpc.CidrBlock, 1, 8 ]] + And: !And [C1, C2] + Equals: !Equals [C1, !Ref RefC2] + Or: !Or [C1, C2] + Size: !If [CreateLargeSize, 100, 10] + SizeRef: !If [CreateLargeSize, !Ref Value1, !Ref Value2] + AutoScalingRollingUpdate: + !If + - RollingUpdates + - + MaxBatchSize: 2 + MinInstancesInService: 2 + PauseTime: PT0M30S + - !Ref AWS::NoValue + Not: !Not [CreateLargeSize, !Ref Value1, !Ref Value2] + ImageId: !FindInMap + - RegionMap + - !Ref AWS::Region + - HVM64 + GetAtt1: !GetAtt DNSName + GetAtt2: !GetAtt myELB.DNSName + GetAtt3: !GetAtt myELB.DNSName.Foo + GetAZs1: !GetAZs "" + GetAZs2: !GetAZs + Ref: AWS::Region + GetAZs3: !GetAZs us-east-1 + ImportValue1: SomeValue + ImportValue2: + - !ImportValue + Fn::Sub: NetworkStackNameParameter-SecurityGroupID + Join1: !Join [ ":", [ a, b, c ] ] + Join2: !Join + - "-" + - - "arn:" + - !Ref AWS::Partition + - :s3:::elasticbeanstalk-*- + - !Ref AWS::AccountId + Select: !Select [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] + Split: !Split [ "|" , "a|b|c" ] + Sub1: !Sub + - www.\${Domain} + - { Domain: !Ref RootDomainName } + Sub2: !Sub "arn:aws:ec2:\${AWS::Region}:\${AWS::AccountId}:vpc/\${vpc}" + Transform: + !Transform + Name: "AWS::Include" + Parameters: + Location: Loc + Ref1: !Ref SomeOtherValue + `; + + ((fs_mock.readFile as unknown) as jest.MockedFunction).mockResolvedValueOnce(yamlContent); + + const result = await readCFNTemplate(testPath); + + await writeCFNTemplate(result.cfnTemplate, testPath, { templateFormat: CFNTemplateFormat.YAML }); + + const writtenYaml = fs_mock.writeFile.mock.calls[0][1]; + + ((fs_mock.readFile as unknown) as jest.MockedFunction).mockResolvedValueOnce(writtenYaml); + + const roundtrippedYaml = await readCFNTemplate(testPath); + + expect(result).toMatchObject(roundtrippedYaml); + }); +}); diff --git a/packages/amplify-cli-core/src/cfnUtilities.ts b/packages/amplify-cli-core/src/cfnUtilities.ts index 3b0765da572..3090c8ae9ab 100644 --- a/packages/amplify-cli-core/src/cfnUtilities.ts +++ b/packages/amplify-cli-core/src/cfnUtilities.ts @@ -48,41 +48,42 @@ export async function writeCFNTemplate(template: object, filePath: string, optio } // Register custom tags for yaml parser +// Order and definition based on docs: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html const CF_SCHEMA = new yaml.Schema([ - new yaml.Type('!Ref', { + new yaml.Type('!Base64', { kind: 'scalar', construct: function (data) { - return { Ref: data }; + return { 'Fn::Base64': data }; }, }), - new yaml.Type('!Condition', { - kind: 'sequence', + new yaml.Type('!Base64', { + kind: 'mapping', construct: function (data) { - return { Condition: data }; + return { 'Fn::Base64': data }; }, }), - new yaml.Type('!Equals', { + new yaml.Type('!Cidr', { kind: 'sequence', construct: function (data) { - return { 'Fn::Equals': data }; + return { 'Fn::Cidr': data }; }, }), - new yaml.Type('!Not', { - kind: 'sequence', + new yaml.Type('!Cidr', { + kind: 'mapping', construct: function (data) { - return { 'Fn::Not': data }; + return { 'Fn::Cidr': data }; }, }), - new yaml.Type('!Sub', { - kind: 'scalar', + new yaml.Type('!And', { + kind: 'sequence', construct: function (data) { - return { 'Fn::Sub': data }; + return { 'Fn::And': data }; }, }), - new yaml.Type('!Sub', { + new yaml.Type('!Equals', { kind: 'sequence', construct: function (data) { - return { 'Fn::Sub': data }; + return { 'Fn::Equals': data }; }, }), new yaml.Type('!If', { @@ -91,16 +92,22 @@ const CF_SCHEMA = new yaml.Schema([ return { 'Fn::If': data }; }, }), - new yaml.Type('!Join', { + new yaml.Type('!Not', { kind: 'sequence', construct: function (data) { - return { 'Fn::Join': data }; + return { 'Fn::Not': data }; }, }), - new yaml.Type('!Select', { + new yaml.Type('!Or', { kind: 'sequence', construct: function (data) { - return { 'Fn::Select': data }; + return { 'Fn::Or': data }; + }, + }), + new yaml.Type('!Condition', { + kind: 'scalar', + construct: function (data) { + return { Condition: data }; }, }), new yaml.Type('!FindInMap', { @@ -145,46 +152,64 @@ const CF_SCHEMA = new yaml.Schema([ return { 'Fn::GetAZs': data }; }, }), - new yaml.Type('!Base64', { + new yaml.Type('!GetAZs', { kind: 'mapping', construct: function (data) { - return { 'Fn::Base64': data }; + return { 'Fn::GetAZs': data }; }, }), - new yaml.Type('!Split', { - kind: 'sequence', + new yaml.Type('!ImportValue', { + kind: 'scalar', construct: function (data) { - return { 'Fn::Split': data }; + return { 'Fn::ImportValue': data }; }, }), - new yaml.Type('!Cidr', { - kind: 'sequence', + new yaml.Type('!ImportValue', { + kind: 'mapping', construct: function (data) { - return { 'Fn::Cidr': data }; + return { 'Fn::ImportValue': data }; }, }), - new yaml.Type('!ImportValue', { + new yaml.Type('!Join', { kind: 'sequence', construct: function (data) { - return { 'Fn::ImportValue': data }; + return { 'Fn::Join': data }; }, }), - new yaml.Type('!Transform', { + new yaml.Type('!Select', { kind: 'sequence', construct: function (data) { - return { 'Fn::Transform': data }; + return { 'Fn::Select': data }; }, }), - new yaml.Type('!And', { + new yaml.Type('!Split', { kind: 'sequence', construct: function (data) { - return { 'Fn::And': data }; + return { 'Fn::Split': data }; }, }), - new yaml.Type('!Or', { + new yaml.Type('!Sub', { + kind: 'scalar', + construct: function (data) { + return { 'Fn::Sub': data }; + }, + }), + new yaml.Type('!Sub', { kind: 'sequence', construct: function (data) { - return { 'Fn::Or': data }; + return { 'Fn::Sub': data }; + }, + }), + new yaml.Type('!Transform', { + kind: 'mapping', + construct: function (data) { + return { 'Fn::Transform': data }; + }, + }), + new yaml.Type('!Ref', { + kind: 'scalar', + construct: function (data) { + return { Ref: data }; }, }), ]);