'use strict' const chai = require('chai') const sinon = require('sinon') const runServerless = require('../../../../../utils/run-serverless') chai.use(require('chai-as-promised')) chai.use(require('sinon-chai')) const expect = require('chai').expect describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { const baseAwsRequestStubMap = { STS: { getCallerIdentity: { ResponseMetadata: { RequestId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' }, UserId: 'XXXXXXXXXXXXXXXXXXXXX', Account: '999999999999', Arn: 'arn:aws:iam::999999999999:user/test', }, }, } describe('with direct create/update calls', () => { it('with nonexistent stack - first deploy', async () => { const describeStacksStub = sinon .stub() .onFirstCall() .throws('error', 'stack does not exist') .onSecondCall() .resolves({ Stacks: [{}] }) const createStackStub = sinon.stub().resolves({}) const updateStackStub = sinon.stub().resolves({}) const s3UploadStub = sinon.stub().resolves() const deleteObjectsStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: { Contents: [] }, upload: s3UploadStub, headBucket: {}, }, CloudFormation: { describeStacks: describeStacksStub, createStack: createStackStub, updateStack: updateStackStub, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'CREATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { provider: { deploymentMethod: 'direct', }, }, }) expect(createStackStub).to.be.calledOnce expect(updateStackStub).to.be.calledOnce const wasCloudFormationTemplateUploadInitiated = s3UploadStub.args.some( (call) => call[0].Key.endsWith('compiled-cloudformation-template.json'), ) expect(wasCloudFormationTemplateUploadInitiated).to.be.true expect(deleteObjectsStub).not.to.be.called }) it('with nonexistent stack - first deploy with custom deployment bucket', async () => { const describeStacksStub = sinon .stub() .onFirstCall() .throws('error', 'stack does not exist') .onSecondCall() .resolves({ Stacks: [{}] }) const createStackStub = sinon.stub().resolves({}) const updateStackStub = sinon.stub().resolves({}) const s3UploadStub = sinon.stub().resolves() const deleteObjectsStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: { Contents: [] }, upload: s3UploadStub, headBucket: {}, getBucketLocation: () => { return { LocationConstraint: 'us-east-1', } }, }, CloudFormation: { describeStacks: describeStacksStub, createStack: createStackStub, updateStack: updateStackStub, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'CREATE_COMPLETE', }, ], }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { provider: { deploymentBucket: 'existing-s3-bucket', deploymentMethod: 'direct', }, }, }) expect(createStackStub).to.be.calledOnce expect(updateStackStub).not.to.be.called const wasCloudFormationTemplateUploadInitiated = s3UploadStub.args.some( (call) => call[0].Key.endsWith('compiled-cloudformation-template.json'), ) expect(wasCloudFormationTemplateUploadInitiated).to.be.true expect(deleteObjectsStub).not.to.be.called }) it('with existing stack - subsequent deploy', async () => { const s3BucketPrefix = 'serverless/test-aws-deploy-with-existing-stack/dev' const s3UploadStub = sinon.stub().resolves() const createStackStub = sinon.stub().resolves({}) const updateStackStub = sinon.stub().resolves({}) const listObjectsV2Stub = sinon .stub() .onFirstCall() .resolves({ Contents: [] }) .onSecondCall() .resolves({ Contents: [ { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json`, }, { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/artifact.zip`, }, { Key: `${s3BucketPrefix}/1589988704352-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json`, }, { Key: `${s3BucketPrefix}/1589988704352-2020-05-20T15:31:44.359Z/artifact.zip`, }, ], }) const deleteObjectsStub = sinon.stub().resolves() const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: listObjectsV2Stub, upload: s3UploadStub, headBucket: {}, }, CloudFormation: { describeStacks: { Stacks: [{}] }, createStack: createStackStub, updateStack: updateStackStub, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'UPDATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { // Default, non-deterministic service-name invalidates this test as S3 Bucket cleanup relies on it service: 'test-aws-deploy-with-existing-stack', provider: { deploymentMethod: 'direct', deploymentBucket: { maxPreviousDeploymentArtifacts: 1, }, }, }, }) expect(createStackStub).not.to.be.called expect(updateStackStub).to.be.calledOnce const wasCloudFormationTemplateUploadInitiated = s3UploadStub.args.some( (call) => call[0].Key.endsWith('compiled-cloudformation-template.json'), ) expect(wasCloudFormationTemplateUploadInitiated).to.be.true expect(deleteObjectsStub).to.be.calledWithExactly({ Bucket: 's3-bucket-resource', Delete: { Objects: [ { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json`, }, { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/artifact.zip`, }, ], }, }) }) it('with existing stack - with deployment bucket resource missing from CloudFormation template', async () => { const createStackStub = sinon.stub().resolves({}) const updateStackStub = sinon.stub().resolves({}) const describeStackResourceStub = sinon .stub() .onFirstCall() .throws(() => { const err = new Error('does not exist for stack') err.providerError = { code: 'ValidationError', } return err }) .onSecondCall() .resolves({ StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { listObjectsV2: { Contents: [] }, headBucket: () => { const err = new Error() err.code = 'AWS_S3_HEAD_BUCKET_NOT_FOUND' throw err }, }, CloudFormation: { describeStacks: { Stacks: [{}] }, validateTemplate: {}, createStack: createStackStub, updateStack: updateStackStub, getTemplate: () => { return { TemplateBody: JSON.stringify({}), } }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'UPDATE_COMPLETE', }, ], }, describeStackResource: describeStackResourceStub, }, } const { serverless, awsNaming } = await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { provider: { deploymentMethod: 'direct', }, }, lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', }) expect(createStackStub).not.to.be.called expect(updateStackStub).to.be.calledWithExactly({ StackName: awsNaming.getStackName(), Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], Parameters: [], NotificationARNs: [], Tags: [{ Key: 'STAGE', Value: 'dev' }], TemplateBody: JSON.stringify({ Resources: serverless.service.provider.coreCloudFormationTemplate.Resources, Outputs: serverless.service.provider.coreCloudFormationTemplate.Outputs, }), }) }) describe('custom deployment-related properties', () => { let createStackStub let updateStackStub const deploymentRole = 'arn:xxx' const notificationArns = ['arn:xxx', 'arn:yyy'] const stackParameters = [ { ParameterKey: 'key', ParameterValue: 'val', }, { ParameterKey: 'key2', ParameterValue: 'val2', }, ] const stackPolicy = [ { Effect: 'Allow', Principal: '*', Action: ['Update:*'], Resource: '*', }, ] const rollbackConfiguration = { MonitoringTimeInMinutes: 20, } const disableRollback = true const stackTags = { TAG: 'value', ANOTHERTAG: 'anotherval', } before(async () => { const describeStacksStub = sinon .stub() .onFirstCall() .throws('error', 'stack does not exist') .onSecondCall() .resolves({ Stacks: [{}] }) createStackStub = sinon.stub().resolves({}) updateStackStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: {}, listObjectsV2: { Contents: [] }, upload: {}, headBucket: {}, }, CloudFormation: { describeStacks: describeStacksStub, createStack: createStackStub, updateStack: updateStackStub, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'CREATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { provider: { deploymentMethod: 'direct', notificationArns, rollbackConfiguration, stackParameters, stackPolicy, stackTags, disableRollback, iam: { deploymentRole, }, }, }, }) }) it('should support custom deployment role', () => { expect(createStackStub.getCall(0).args[0].RoleARN).to.equal( deploymentRole, ) expect(updateStackStub.getCall(0).args[0].RoleARN).to.equal( deploymentRole, ) }) it('should support `notificationsArns`', () => { expect( createStackStub.getCall(0).args[0].NotificationARNs, ).to.deep.equal(notificationArns) expect( updateStackStub.getCall(0).args[0].NotificationARNs, ).to.deep.equal(notificationArns) }) it('should support `stackParameters`', () => { expect(createStackStub.getCall(0).args[0].Parameters).to.deep.equal( stackParameters, ) expect(updateStackStub.getCall(0).args[0].Parameters).to.deep.equal( stackParameters, ) }) it('should support `stackPolicy`', () => { expect( updateStackStub.getCall(0).args[0].StackPolicyBody, ).to.deep.equal(JSON.stringify({ Statement: stackPolicy })) }) it('should support `rollbackConfiguration`', () => { expect( updateStackStub.getCall(0).args[0].RollbackConfiguration, ).to.deep.equal(rollbackConfiguration) }) it('should support `disableRollback`', () => { expect(createStackStub.getCall(0).args[0].DisableRollback).to.be.true expect(updateStackStub.getCall(0).args[0].DisableRollback).to.be.true }) it('should support `stackTags`', () => { expect(createStackStub.getCall(0).args[0].Tags).to.deep.equal([ { Key: 'STAGE', Value: 'dev' }, { Key: 'TAG', Value: 'value' }, { Key: 'ANOTHERTAG', Value: 'anotherval' }, ]) expect(updateStackStub.getCall(0).args[0].Tags).to.deep.equal([ { Key: 'STAGE', Value: 'dev' }, { Key: 'TAG', Value: 'value' }, { Key: 'ANOTHERTAG', Value: 'anotherval' }, ]) }) }) }) describe('with change-sets', () => { it('with nonexistent stack - first deploy with custom deployment bucket', async () => { const describeStacksStub = sinon .stub() .onFirstCall() .throws('error', 'stack does not exist') .onSecondCall() .resolves({ Stacks: [{}] }) const createChangeSetStub = sinon.stub().resolves({}) const executeChangeSetStub = sinon.stub().resolves({}) const s3UploadStub = sinon.stub().resolves() const deleteObjectsStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: { Contents: [] }, upload: s3UploadStub, headBucket: {}, getBucketLocation: () => { return { LocationConstraint: 'us-east-1', } }, }, CloudFormation: { describeStacks: describeStacksStub, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, deleteChangeSet: {}, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'CREATE_COMPLETE', }, ], }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { provider: { deploymentBucket: 'existing-s3-bucket', }, }, }) expect(createChangeSetStub).to.be.calledOnce expect(createChangeSetStub.getCall(0).args[0].ChangeSetType).to.equal( 'CREATE', ) expect(executeChangeSetStub).to.be.calledOnce const wasCloudFormationTemplateUploadInitiated = s3UploadStub.args.some( (call) => call[0].Key.endsWith('compiled-cloudformation-template.json'), ) expect(wasCloudFormationTemplateUploadInitiated).to.be.true expect(deleteObjectsStub).not.to.be.called }) it('with nonexistent stack - first deploy', async () => { const describeStacksStub = sinon .stub() .onFirstCall() .throws('error', 'stack does not exist') .onSecondCall() .resolves({ Stacks: [{}] }) const createChangeSetStub = sinon.stub().resolves({}) const executeChangeSetStub = sinon.stub().resolves({}) const s3UploadStub = sinon.stub().resolves() const deleteObjectsStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: { Contents: [] }, upload: s3UploadStub, headBucket: {}, }, CloudFormation: { describeStacks: describeStacksStub, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, deleteChangeSet: {}, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'CREATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, }) expect(createChangeSetStub).to.be.calledTwice expect(createChangeSetStub.getCall(0).args[0].ChangeSetType).to.equal( 'CREATE', ) expect(createChangeSetStub.getCall(1).args[0].ChangeSetType).to.equal( 'UPDATE', ) expect(executeChangeSetStub).to.be.calledTwice const wasCloudFormationTemplateUploadInitiated = s3UploadStub.args.some( (call) => call[0].Key.endsWith('compiled-cloudformation-template.json'), ) expect(wasCloudFormationTemplateUploadInitiated).to.be.true expect(deleteObjectsStub).not.to.be.called }) it('with nonexistent stack - should output an appropriate error message for an abnormal stack state', async () => { const describeStacksStub = sinon .stub() .onFirstCall() .resolves({ Stacks: [ { StackStatus: 'REVIEW_IN_PROGRESS', }, ], }) const createChangeSetStub = sinon.stub().resolves({}) const executeChangeSetStub = sinon.stub().resolves({}) const s3UploadStub = sinon.stub().resolves() const deleteObjectsStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: { Contents: [] }, upload: s3UploadStub, headBucket: {}, }, CloudFormation: { describeStacks: describeStacksStub, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, deleteChangeSet: {}, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, describeStackEvents: {}, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await expect( runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, }), ).to.have.been.eventually.rejected.with.property( 'code', 'AWS_CLOUDFORMATION_INACTIVE_STACK', ) }) it('with existing stack - subsequent deploy', async () => { const s3BucketPrefix = 'serverless/test-aws-deploy-with-existing-stack/dev' const s3UploadStub = sinon.stub().resolves() const createChangeSetStub = sinon.stub().resolves({}) const executeChangeSetStub = sinon.stub().resolves({}) const listObjectsV2Stub = sinon .stub() .onFirstCall() .resolves({ Contents: [] }) .onSecondCall() .resolves({ Contents: [ { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json`, }, { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/artifact.zip`, }, { Key: `${s3BucketPrefix}/1589988704352-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json`, }, { Key: `${s3BucketPrefix}/1589988704352-2020-05-20T15:31:44.359Z/artifact.zip`, }, ], }) const deleteObjectsStub = sinon.stub().resolves() const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: listObjectsV2Stub, upload: s3UploadStub, headBucket: {}, }, CloudFormation: { describeStacks: { Stacks: [{}] }, deleteChangeSet: {}, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'UPDATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { // Default, non-deterministic service-name invalidates this test as S3 Bucket cleanup relies on it service: 'test-aws-deploy-with-existing-stack', provider: { deploymentBucket: { maxPreviousDeploymentArtifacts: 1, }, }, }, }) expect(createChangeSetStub).to.be.calledOnce expect(createChangeSetStub.getCall(0).args[0].ChangeSetType).to.equal( 'UPDATE', ) expect(executeChangeSetStub).to.be.calledOnce const wasCloudFormationTemplateUploadInitiated = s3UploadStub.args.some( (call) => call[0].Key.endsWith('compiled-cloudformation-template.json'), ) expect(wasCloudFormationTemplateUploadInitiated).to.be.true expect(deleteObjectsStub).to.be.calledWithExactly({ Bucket: 's3-bucket-resource', Delete: { Objects: [ { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json`, }, { Key: `${s3BucketPrefix}/1589988704351-2020-05-20T15:31:44.359Z/artifact.zip`, }, ], }, }) }) it('with existing stack - subsequent deploy with empty changeset', async () => { const createChangeSetStub = sinon.stub().resolves({}) const executeChangeSetStub = sinon.stub().resolves({}) const deleteChangeSetStub = sinon.stub().resolves() const deleteObjectsStub = sinon.stub().resolves() let objectsToRemove const listObjectsV2Stub = sinon .stub() .onFirstCall() .resolves({ Contents: [] }) .onSecondCall() .callsFake((params) => { objectsToRemove = [ { Key: `${params.Prefix}/compiled-cloudformation-template.json`, }, { Key: `${params.Prefix}/artifact.zip`, }, ] return { Contents: objectsToRemove, } }) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: deleteObjectsStub, listObjectsV2: listObjectsV2Stub, upload: {}, headBucket: {}, }, CloudFormation: { describeStacks: { Stacks: [{}] }, deleteChangeSet: deleteChangeSetStub, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'FAILED', StatusReason: 'No updates are to be performed.', }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, }) expect(createChangeSetStub).to.be.calledOnce expect(createChangeSetStub.getCall(0).args[0].ChangeSetType).to.equal( 'UPDATE', ) expect(executeChangeSetStub).not.to.be.called expect(deleteChangeSetStub).to.be.calledTwice expect(deleteObjectsStub).to.be.calledWithExactly({ Bucket: 's3-bucket-resource', Delete: { Objects: objectsToRemove }, }) }) it('should fail if cannot create a change set', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: {}, listObjectsV2: { Contents: [] }, upload: {}, headBucket: {}, }, CloudFormation: { describeStacks: { Stacks: [{}] }, deleteChangeSet: {}, createChangeSet: {}, executeChangeSet: {}, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'FAILED', StatusReason: 'Some internal reason', }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await expect( runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, }), ).to.have.been.eventually.rejected.with.property( 'code', 'AWS_CLOUD_FORMATION_CHANGE_SET_CREATION_FAILED', ) }) it('with existing stack - with deployment bucket resource missing from CloudFormation template', async () => { const createChangeSetStub = sinon.stub().resolves({}) const executeChangeSetStub = sinon.stub().resolves({}) const describeStackResourceStub = sinon .stub() .onFirstCall() .throws(() => { const err = new Error('does not exist for stack') err.providerError = { code: 'ValidationError', } return err }) .onSecondCall() .resolves({ StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { listObjectsV2: { Contents: [] }, headBucket: () => { const err = new Error() err.code = 'AWS_S3_HEAD_BUCKET_NOT_FOUND' throw err }, }, CloudFormation: { describeStacks: { Stacks: [{}] }, validateTemplate: {}, deleteChangeSet: {}, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, getTemplate: () => { return { TemplateBody: JSON.stringify({}), } }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'UPDATE_COMPLETE', }, ], }, describeStackResource: describeStackResourceStub, }, } const { serverless, awsNaming } = await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', }) expect(createChangeSetStub).to.be.calledWithExactly({ StackName: awsNaming.getStackName(), ChangeSetName: awsNaming.getStackChangeSetName(), ChangeSetType: 'UPDATE', Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], Parameters: [], NotificationARNs: [], Tags: [{ Key: 'STAGE', Value: 'dev' }], TemplateBody: JSON.stringify({ Resources: serverless.service.provider.coreCloudFormationTemplate.Resources, Outputs: serverless.service.provider.coreCloudFormationTemplate.Outputs, }), }) expect(executeChangeSetStub).to.be.calledWithExactly({ StackName: awsNaming.getStackName(), ChangeSetName: awsNaming.getStackChangeSetName(), }) }) describe('custom deployment-related properties', () => { let createChangeSetStub let executeChangeSetStub let setStackPolicyStub const deploymentRole = 'arn:xxx' const notificationArns = ['arn:xxx', 'arn:yyy'] const stackParameters = [ { ParameterKey: 'key', ParameterValue: 'val', }, { ParameterKey: 'key2', ParameterValue: 'val2', }, ] const stackPolicy = [ { Effect: 'Allow', Principal: '*', Action: ['Update:*'], Resource: '*', }, ] const rollbackConfiguration = { MonitoringTimeInMinutes: 20, } const disableRollback = true const stackTags = { TAG: 'value', ANOTHERTAG: 'anotherval', } before(async () => { const describeStacksStub = sinon .stub() .onFirstCall() .throws('error', 'stack does not exist') .onSecondCall() .resolves({ Stacks: [{}] }) createChangeSetStub = sinon.stub().resolves({}) executeChangeSetStub = sinon.stub().resolves({}) setStackPolicyStub = sinon.stub().resolves({}) const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { deleteObjects: {}, listObjectsV2: { Contents: [] }, upload: {}, headBucket: {}, }, CloudFormation: { describeStacks: describeStacksStub, createChangeSet: createChangeSetStub, executeChangeSet: executeChangeSetStub, deleteChangeSet: {}, describeChangeSet: { ChangeSetName: 'new-service-dev-change-set', ChangeSetId: 'some-change-set-id', StackName: 'new-service-dev', Status: 'CREATE_COMPLETE', }, setStackPolicy: setStackPolicyStub, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'CREATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } await runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, configExt: { provider: { notificationArns, rollbackConfiguration, stackParameters, stackPolicy, stackTags, disableRollback, iam: { deploymentRole, }, }, }, }) }) it('should support custom deployment role', () => { expect(createChangeSetStub.getCall(0).args[0].RoleARN).to.equal( deploymentRole, ) expect(createChangeSetStub.getCall(1).args[0].RoleARN).to.equal( deploymentRole, ) }) it('should support `notificationsArns`', () => { expect( createChangeSetStub.getCall(0).args[0].NotificationARNs, ).to.deep.equal(notificationArns) expect( createChangeSetStub.getCall(1).args[0].NotificationARNs, ).to.deep.equal(notificationArns) }) it('should support `stackParameters`', () => { expect(createChangeSetStub.getCall(1).args[0].Parameters).to.deep.equal( stackParameters, ) }) it('should support `stackPolicy`', () => { expect(setStackPolicyStub.getCall(0).args[0].StackPolicyBody).to.equal( JSON.stringify({ Statement: stackPolicy }), ) }) it('should only set `stackPolicy` after applying change set', () => { expect(setStackPolicyStub).to.not.be.calledBefore(executeChangeSetStub) }) it('should support `rollbackConfiguration`', () => { expect( createChangeSetStub.getCall(1).args[0].RollbackConfiguration, ).to.deep.equal(rollbackConfiguration) }) it('should support `disableRollback`', () => { expect(executeChangeSetStub.getCall(0).args[0].DisableRollback).to.be .true expect(executeChangeSetStub.getCall(1).args[0].DisableRollback).to.be .true }) it('should support `stackTags`', () => { expect(createChangeSetStub.getCall(0).args[0].Tags).to.deep.equal([ { Key: 'STAGE', Value: 'dev' }, { Key: 'TAG', Value: 'value' }, { Key: 'ANOTHERTAG', Value: 'anotherval' }, ]) expect(createChangeSetStub.getCall(1).args[0].Tags).to.deep.equal([ { Key: 'STAGE', Value: 'dev' }, { Key: 'TAG', Value: 'value' }, { Key: 'ANOTHERTAG', Value: 'anotherval' }, ]) }) }) }) it('with existing stack - should skip deploy if nothing changed', async () => { const s3UploadStub = sinon.stub().resolves() const listObjectsV2Stub = sinon.stub().resolves({ Contents: [ { Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json', LastModified: new Date(), ETag: '"5102a4cf710cae6497dba9e61b85d0a4"', Size: 356, StorageClass: 'STANDARD', }, { Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/serverless-state.json', LastModified: new Date(), ETag: '"5102a4cf710cae6497dba9e61b85d0a4"', Size: 356, StorageClass: 'STANDARD', }, { Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/my-own.zip', LastModified: new Date(), ETag: '"5102a4cf710cae6497dba9e61b85d0a4"', Size: 356, StorageClass: 'STANDARD', }, ], }) const s3HeadObjectStub = sinon.stub() s3HeadObjectStub .withArgs({ Bucket: 's3-bucket-resource', Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/compiled-cloudformation-template.json', }) .returns({ Metadata: { filesha256: 'sazQTKx8BgZJIMV2cJhXcOT68Q8KaP9mHdI9C2dST40=', }, }) s3HeadObjectStub .withArgs({ Bucket: 's3-bucket-resource', Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/serverless-state.json', }) .returns({ Metadata: { filesha256: 'fSU2tLfTe72BW+k8hJvm6VkzHtssCtrTG+uqGGg4YzI=', }, }) s3HeadObjectStub .withArgs({ Bucket: 's3-bucket-resource', Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/my-own.zip', }) .returns({ Metadata: { filesha256: 'T0qEYHOE4Xv2E8Ar03xGogAlElcdf/dQh/lh9ao7Glo=', }, }) const awsRequestStubMap = { ...baseAwsRequestStubMap, S3: { headObject: s3HeadObjectStub, listObjectsV2: listObjectsV2Stub, upload: s3UploadStub, headBucket: {}, }, CloudFormation: { describeStacks: { Stacks: [{}] }, describeStackEvents: { StackEvents: [ { EventId: '1e2f3g4h', StackName: 'new-service-dev', LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), ResourceStatus: 'UPDATE_COMPLETE', }, ], }, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, validateTemplate: {}, listStackResources: {}, }, } const { serverless } = await runServerless({ fixture: 'package-artifact-in-serverless-dir', command: 'deploy', awsRequestStubMap, configExt: { // Default, non-deterministic service-name invalidates this test service: 'test-aws-deploy-should-be-skipped', }, }) expect(serverless.service.provider.shouldNotDeploy).to.be.true expect(s3UploadStub).to.not.be.called }) it('with existing stack - missing custom deployment bucket', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { getBucketLocation: () => { throw new Error() }, }, CloudFormation: { describeStacks: { Stacks: [{}] }, validateTemplate: {}, }, } await expect( runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', configExt: { provider: { deploymentBucket: 'bucket-name', }, }, }), ).to.eventually.have.been.rejected.and.have.property( 'code', 'DEPLOYMENT_BUCKET_NOT_FOUND', ) }) it('with existing stack - with custom deployment bucket in different region', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { getBucketLocation: () => { return { LocationConstraint: 'us-west-1', } }, }, CloudFormation: { describeStacks: { Stacks: [{}] }, validateTemplate: {}, }, } await expect( runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', configExt: { provider: { deploymentBucket: 'bucket-name', }, }, }), ).to.eventually.have.been.rejected.and.have.property( 'code', 'DEPLOYMENT_BUCKET_INVALID_REGION', ) }) it('with existing stack - with deployment bucket from CloudFormation deleted manually', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { headBucket: () => { const err = new Error() err.code = 'AWS_S3_HEAD_BUCKET_NOT_FOUND' throw err }, }, CloudFormation: { describeStacks: { Stacks: [{}] }, validateTemplate: {}, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, }, } await expect( runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', }), ).to.eventually.have.been.rejected.and.have.property( 'code', 'DEPLOYMENT_BUCKET_REMOVED_MANUALLY', ) }) it('should throw when deployment bucket cannot be accessed', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { describeRepositories: sinon.stub().throws({ providerError: { code: 'RepositoryNotFoundException' }, }), }, S3: { headBucket: () => { const err = new Error() err.code = 'AWS_S3_HEAD_BUCKET_FORBIDDEN' throw err }, }, CloudFormation: { describeStacks: { Stacks: [{}] }, validateTemplate: {}, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, }, }, } await expect( runServerless({ fixture: 'function', command: 'deploy', awsRequestStubMap, lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', }), ).to.eventually.have.been.rejected.and.have.property( 'code', 'AWS_S3_HEAD_BUCKET_FORBIDDEN', ) }) })