diff --git a/docs/cli-reference/remove.md b/docs/cli-reference/remove.md index a2bbdcf2ab..4b0c081f4d 100644 --- a/docs/cli-reference/remove.md +++ b/docs/cli-reference/remove.md @@ -2,6 +2,8 @@ The `osls remove` command will remove the deployed service, defined in your current working directory, from the provider. +If the stack has deletion protection enabled (see [`provider.deletionProtection`](../guides/deploying.md#deletion-protection)), the command fails before deleting anything. + ```bash osls remove ``` diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index d8055c82e5..00161855d9 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -44,6 +44,39 @@ provider: deploymentMethod: direct ``` +### Deletion protection + +Set `provider.deletionProtection` to have osls manage [CloudFormation termination protection](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html) for the service stack: + +```yaml +provider: + name: aws + deletionProtection: true +``` + +To protect only some stages, list them: + +```yaml +provider: + name: aws + deletionProtection: + stages: + - prod +``` + +After every successful `osls deploy`, including deploys that are skipped because nothing changed, osls sets the stack's termination protection to match the configuration: enabled when the value is `true` or the current stage is listed in `stages`, disabled otherwise. With the `stages` form, deploying an unlisted stage therefore actively disables protection on that stage's stack. Third-party termination protection plugins typically only ever enable protection, so check the `stages` list when migrating from one. `osls deploy function` and `osls rollback` never change the setting, and removing `provider.deletionProtection` from `serverless.yml` does not disable protection on an existing stack; it only stops osls from managing it. + +While a stack is protected, `osls remove` fails early with `AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED`, before any deployment artifacts are deleted, and deleting the stack in the AWS console or CLI is rejected by CloudFormation. To remove the service, set `provider.deletionProtection` to `false` (or drop the stage from `stages`), deploy, then remove. If deploying is not possible, for example because the stack is stuck in a failed state, disable protection directly with `aws cloudformation update-termination-protection --no-enable-termination-protection --stack-name `. + +Keep in mind: + +- The deploying identity needs `cloudformation:UpdateTerminationProtection` on the stack. `osls remove` uses `cloudformation:DescribeStacks` to check the flag; if that call is denied, osls logs a warning and continues, and CloudFormation still refuses to delete a protected stack. +- Protection is applied after the stack has been created or updated, so a brand-new stack is unprotected until its first deployment completes. +- An invalid value fails the deploy with `INVALID_DELETION_PROTECTION_CONFIG` before anything is uploaded. Configuration validation already rejects most invalid shapes; this also covers `configValidationMode: warn` and `off`. +- `osls deploy --package` uses the value saved by `osls package`; re-run `osls package` after changing it. +- Nested stacks inherit the root stack's setting. +- Termination protection prevents accidents, not malicious deletion: anyone allowed to call `UpdateTerminationProtection` can turn it off, and deploying with `deletionProtection: false` does exactly that. + ### Tips - Use this in your CI/CD systems, as it is the safest method of deployment. diff --git a/docs/guides/serverless.yml.md b/docs/guides/serverless.yml.md index 072e2fe0cb..4757736f44 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -82,6 +82,13 @@ provider: key: value # Method used for CloudFormation deployments: 'changesets' or 'direct' (default: changesets) deploymentMethod: direct + # Manage CloudFormation termination protection for the stack after each deploy (not managed by default). + # `true`/`false` applies to every stage; the `stages` form enables protection for the listed + # stages and disables it for all others. + deletionProtection: true + # deletionProtection: + # stages: + # - prod # List of existing Amazon SNS topics in the same region where notifications about stack events are sent. notificationArns: - 'arn:aws:sns:us-east-1:XXXXXX:mytopic' diff --git a/lib/plugins/aws/deletion-protection.js b/lib/plugins/aws/deletion-protection.js new file mode 100644 index 0000000000..802ac587cc --- /dev/null +++ b/lib/plugins/aws/deletion-protection.js @@ -0,0 +1,93 @@ +'use strict'; + +const { + CloudFormationClient, + UpdateTerminationProtectionCommand, +} = require('@aws-sdk/client-cloudformation'); +const ServerlessError = require('../../serverless-error'); +const { log } = require('../../utils/serverless-utils/log'); +const { getAwsErrorMessage } = require('../../aws/aws-sdk-v3-error'); + +class AwsDeletionProtection { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options || {}; + this.provider = this.serverless.getProvider('aws'); + + this.hooks = { + 'before:deploy:deploy': async () => this.validateConfiguration(), + 'after:deploy:deploy': async () => this.postDeploy(), + }; + } + + resolveDeletionProtection() { + const setting = this.serverless.service.provider.deletionProtection; + if (setting == null) return null; + if (typeof setting === 'boolean') return { enabled: setting }; + if ( + typeof setting === 'object' && + Array.isArray(setting.stages) && + setting.stages.length > 0 && + setting.stages.every((stage) => typeof stage === 'string') + ) { + return { enabled: setting.stages.includes(this.provider.getStage()) }; + } + throw new ServerlessError( + 'provider.deletionProtection must be a boolean or an object with a non-empty stages list', + 'INVALID_DELETION_PROTECTION_CONFIG' + ); + } + + validateConfiguration() { + this.resolveDeletionProtection(); + } + + async postDeploy() { + const config = this.resolveDeletionProtection(); + if (!config) return; + + // A change set based first deploy that ends with an empty change set does not create the + // stack: it is left in REVIEW_IN_PROGRESS and the next deploy asks to run `remove` (see + // `createFallback` in lib/plugins/aws/lib/update-stack.js). Protecting it would block that + // `remove`, so leave it alone until the stack exists + if ( + this.provider.didCreateService && + this.serverless.service.provider.deploymentWithEmptyChangeSet + ) { + log.info('Skipping deletion protection update as the stack has not been created'); + return; + } + + const stackName = this.provider.naming.getStackName(); + const cloudFormation = await this.getCloudFormationClient(); + try { + await cloudFormation.send( + new UpdateTerminationProtectionCommand({ + StackName: stackName, + EnableTerminationProtection: config.enabled, + }) + ); + } catch (error) { + throw new ServerlessError( + `Could not ${config.enabled ? 'enable' : 'disable'} deletion protection for stack ` + + `"${stackName}" (the stack itself was deployed): ${ + getAwsErrorMessage(error) || String(error) + }`, + 'AWS_CLOUDFORMATION_DELETION_PROTECTION_UPDATE_FAILED', + { cause: error } + ); + } + log.info( + `${config.enabled ? 'Enabled' : 'Disabled'} deletion protection for stack "${stackName}"` + ); + } + + async getCloudFormationClient() { + this.cloudFormationClientPromise ||= this.provider + .getAwsSdkV3Config() + .then((config) => new CloudFormationClient(config)); + return this.cloudFormationClientPromise; + } +} + +module.exports = AwsDeletionProtection; diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index 2d89f72f70..d67f70fc60 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -594,6 +594,23 @@ class AwsProvider { awsLambdaTimeout: { type: 'integer', minimum: 1, maximum: 900 }, awsLambdaTracing: { anyOf: [{ enum: ['Active', 'PassThrough'] }, { type: 'boolean' }] }, awsLambdaVersioning: { type: 'boolean' }, + awsDeletionProtection: { + anyOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + stages: { + type: 'array', + minItems: 1, + items: { $ref: '#/definitions/stage' }, + }, + }, + required: ['stages'], + additionalProperties: false, + }, + ], + }, awsPruneFunctionVersions: { anyOf: [ { type: 'boolean' }, @@ -925,6 +942,7 @@ class AwsProvider { }, additionalProperties: false, }, + deletionProtection: { $ref: '#/definitions/awsDeletionProtection' }, deploymentBucket: { anyOf: [ { $ref: '#/definitions/awsS3BucketName' }, diff --git a/lib/plugins/aws/remove/index.js b/lib/plugins/aws/remove/index.js index 264d16f746..286217e5af 100644 --- a/lib/plugins/aws/remove/index.js +++ b/lib/plugins/aws/remove/index.js @@ -49,8 +49,12 @@ class AwsRemove { } }, 'remove:remove': async () => { - const doesEcrRepositoryExistPromise = this.checkIfEcrRepositoryExists(); await this.validate(); + const doesEcrRepositoryExistPromise = this.checkIfEcrRepositoryExists(); + // The probe is only awaited once the stack is gone; do not let a failure of the steps + // in between (e.g. the deletion protection guard) turn its rejection into an unhandled one + doesEcrRepositoryExistPromise.catch(() => {}); + await this.ensureStackIsNotDeletionProtected(); mainProgress.notice('Removing objects from S3 bucket', { isMainEvent: true }); await this.emptyS3Bucket(); mainProgress.notice('Removing CloudFormation stack', { isMainEvent: true }); diff --git a/lib/plugins/aws/remove/lib/stack.js b/lib/plugins/aws/remove/lib/stack.js index c5c61005c1..471965c946 100644 --- a/lib/plugins/aws/remove/lib/stack.js +++ b/lib/plugins/aws/remove/lib/stack.js @@ -1,6 +1,16 @@ 'use strict'; -const { CloudFormationClient, DeleteStackCommand } = require('@aws-sdk/client-cloudformation'); +const { + CloudFormationClient, + DeleteStackCommand, + DescribeStacksCommand, +} = require('@aws-sdk/client-cloudformation'); +const ServerlessError = require('../../../../serverless-error'); +const { log } = require('../../../../utils/serverless-utils/log'); +const { + getAwsErrorMessage, + isCloudFormationMissingStackError, +} = require('../../../../aws/aws-sdk-v3-error'); function getCloudFormationClient(context) { context.cloudFormationClientPromise ||= context.provider @@ -10,6 +20,37 @@ function getCloudFormationClient(context) { } module.exports = { + async ensureStackIsNotDeletionProtected() { + const stackName = this.provider.naming.getStackName(); + const cloudFormation = await getCloudFormationClient(this); + let stack; + + try { + const result = await cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })); + stack = result.Stacks && result.Stacks[0]; + } catch (error) { + if (isCloudFormationMissingStackError(error)) return; + // Removal must not depend on cloudformation:DescribeStacks being granted: fall back to + // the previous behaviour, where CloudFormation itself rejects deleting a protected stack + log.warning( + `Could not check whether stack "${stackName}" has deletion protection enabled ` + + `(${getAwsErrorMessage(error) || String(error)}). Continuing with removal.` + ); + return; + } + + if (!stack || !stack.EnableTerminationProtection) return; + + throw new ServerlessError( + `Cannot remove stack "${stackName}" because deletion protection is enabled. ` + + 'Set provider.deletionProtection to false (or remove this stage from ' + + 'provider.deletionProtection.stages) and deploy the service before removing it. ' + + 'To turn it off without deploying, run: aws cloudformation ' + + `update-termination-protection --no-enable-termination-protection --stack-name ${stackName}`, + 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' + ); + }, + async remove() { const stackName = this.provider.naming.getStackName(); const params = { diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 8d7b2e8fe5..76c9d07dad 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -28,6 +28,7 @@ module.exports = [ require('./aws/remove/index.js'), require('./aws/rollback.js'), require('./aws/rollback-function.js'), + require('./aws/deletion-protection.js'), require('./aws/prune.js'), require('./aws/package/compile/layers.js'), require('./aws/package/compile/functions.js'), diff --git a/test/lib/configure-aws-sdk-v3-stub.js b/test/lib/configure-aws-sdk-v3-stub.js index 1fdba39f62..0fa59ee399 100644 --- a/test/lib/configure-aws-sdk-v3-stub.js +++ b/test/lib/configure-aws-sdk-v3-stub.js @@ -47,6 +47,7 @@ const serviceDefinitions = { deleteChangeSet: 'DeleteChangeSetCommand', getTemplate: 'GetTemplateCommand', setStackPolicy: 'SetStackPolicyCommand', + updateTerminationProtection: 'UpdateTerminationProtectionCommand', describeStackEvents: 'DescribeStackEventsCommand', }, }, diff --git a/test/unit/lib/plugins/aws/deletion-protection.test.js b/test/unit/lib/plugins/aws/deletion-protection.test.js new file mode 100644 index 0000000000..eab7f39419 --- /dev/null +++ b/test/unit/lib/plugins/aws/deletion-protection.test.js @@ -0,0 +1,241 @@ +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const logEmitter = require('log/lib/emitter'); +const { UpdateTerminationProtectionCommand } = require('@aws-sdk/client-cloudformation'); +const AwsDeletionProtection = require('../../../../../lib/plugins/aws/deletion-protection'); +const ServerlessError = require('../../../../../lib/serverless-error'); + +describe('AwsDeletionProtection', () => { + let serverless; + let provider; + let awsDeletionProtection; + + const collectLogEvents = async (fn) => { + const logEvents = []; + const listener = (event) => logEvents.push(event); + logEmitter.on('log', listener); + try { + await fn(); + } finally { + logEmitter.off('log', listener); + } + return logEvents; + }; + + beforeEach(() => { + const options = { stage: 'dev', region: 'us-east-1' }; + provider = { + getStage: sinon.stub().returns('dev'), + getAwsSdkV3Config: sinon.stub().resolves({}), + naming: { getStackName: sinon.stub().returns('service-dev') }, + }; + serverless = { + service: { + provider: { name: 'aws' }, + }, + getProvider: sinon.stub().withArgs('aws').returns(provider), + }; + awsDeletionProtection = new AwsDeletionProtection(serverless, options); + }); + + afterEach(() => sinon.restore()); + + describe('#constructor()', () => { + it('should only hook the deploy lifecycle', () => { + expect(Object.keys(awsDeletionProtection.hooks)).to.deep.equal([ + 'before:deploy:deploy', + 'after:deploy:deploy', + ]); + expect(awsDeletionProtection.hooks['before:deploy:deploy']).to.be.a('function'); + expect(awsDeletionProtection.hooks['after:deploy:deploy']).to.be.a('function'); + }); + }); + + describe('#resolveDeletionProtection()', () => { + it('should return null when not configured', () => { + expect(awsDeletionProtection.resolveDeletionProtection()).to.equal(null); + }); + + it('should enable for true', () => { + serverless.service.provider.deletionProtection = true; + expect(awsDeletionProtection.resolveDeletionProtection()).to.deep.equal({ enabled: true }); + }); + + it('should disable for false', () => { + serverless.service.provider.deletionProtection = false; + expect(awsDeletionProtection.resolveDeletionProtection()).to.deep.equal({ enabled: false }); + }); + + it('should enable when the current stage is listed', () => { + serverless.service.provider.deletionProtection = { stages: ['dev', 'prod'] }; + expect(awsDeletionProtection.resolveDeletionProtection()).to.deep.equal({ enabled: true }); + expect(provider.getStage).to.have.been.called; + }); + + it('should disable when the current stage is not listed', () => { + serverless.service.provider.deletionProtection = { stages: ['prod'] }; + expect(awsDeletionProtection.resolveDeletionProtection()).to.deep.equal({ enabled: false }); + }); + + for (const [description, deletionProtection] of [ + ['an empty object', {}], + ['an empty stages list', { stages: [] }], + ['a string stages value', { stages: 'prod' }], + ['a null stages value', { stages: null }], + ['an object stages value', { stages: {} }], + ['a non-string stage', { stages: ['prod', 1] }], + ['an array', ['prod']], + ['a string', 'prod'], + ['a number', 1], + ]) { + it(`should throw for ${description}`, () => { + serverless.service.provider.deletionProtection = deletionProtection; + expect(() => awsDeletionProtection.resolveDeletionProtection()) + .to.throw(ServerlessError) + .with.property('code', 'INVALID_DELETION_PROTECTION_CONFIG'); + }); + } + }); + + describe('#validateConfiguration()', () => { + it('should not throw when not configured', () => { + expect(() => awsDeletionProtection.validateConfiguration()).to.not.throw(); + }); + + it('should not throw for a valid stages list', () => { + serverless.service.provider.deletionProtection = { stages: ['prod'] }; + expect(() => awsDeletionProtection.validateConfiguration()).to.not.throw(); + }); + + it('should throw for an invalid configuration', () => { + serverless.service.provider.deletionProtection = { stages: [] }; + expect(() => awsDeletionProtection.validateConfiguration()) + .to.throw(ServerlessError) + .with.property('code', 'INVALID_DELETION_PROTECTION_CONFIG'); + }); + + it('should not call AWS', () => { + serverless.service.provider.deletionProtection = true; + awsDeletionProtection.validateConfiguration(); + expect(provider.getAwsSdkV3Config).to.not.have.been.called; + }); + }); + + describe('#postDeploy()', () => { + let send; + + beforeEach(() => { + send = sinon.stub().resolves({}); + sinon.stub(awsDeletionProtection, 'getCloudFormationClient').resolves({ send }); + }); + + it('should do nothing when not configured', async () => { + await awsDeletionProtection.postDeploy(); + expect(awsDeletionProtection.getCloudFormationClient).to.not.have.been.called; + expect(send).to.not.have.been.called; + }); + + it('should enable protection on the stack', async () => { + serverless.service.provider.deletionProtection = true; + await awsDeletionProtection.postDeploy(); + expect(send).to.have.been.calledOnce; + expect(send.firstCall.args[0]).to.be.instanceOf(UpdateTerminationProtectionCommand); + expect(send.firstCall.args[0].input).to.deep.equal({ + StackName: 'service-dev', + EnableTerminationProtection: true, + }); + }); + + it('should disable protection when the stage is not listed', async () => { + serverless.service.provider.deletionProtection = { stages: ['prod'] }; + await awsDeletionProtection.postDeploy(); + expect(send).to.have.been.calledOnce; + expect(send.firstCall.args[0].input).to.deep.equal({ + StackName: 'service-dev', + EnableTerminationProtection: false, + }); + }); + + it('should skip a stack that was not created because of an empty change set', async () => { + serverless.service.provider.deletionProtection = true; + provider.didCreateService = true; + serverless.service.provider.deploymentWithEmptyChangeSet = true; + + const logEvents = await collectLogEvents(() => awsDeletionProtection.postDeploy()); + + expect(send).to.not.have.been.called; + const infoMessages = logEvents + .filter((event) => event.logger.level === 'info') + .map((event) => event.messageTokens[0]); + expect(infoMessages).to.include( + 'Skipping deletion protection update as the stack has not been created' + ); + }); + + it('should still reconcile a subsequent deploy with an empty change set', async () => { + serverless.service.provider.deletionProtection = true; + serverless.service.provider.deploymentWithEmptyChangeSet = true; + await awsDeletionProtection.postDeploy(); + expect(send).to.have.been.calledOnce; + }); + + it('should still reconcile a freshly created stack', async () => { + serverless.service.provider.deletionProtection = true; + provider.didCreateService = true; + await awsDeletionProtection.postDeploy(); + expect(send).to.have.been.calledOnce; + }); + + it('should log the applied state', async () => { + serverless.service.provider.deletionProtection = true; + + const logEvents = await collectLogEvents(() => awsDeletionProtection.postDeploy()); + + const infoMessages = logEvents + .filter((event) => event.logger.level === 'info') + .map((event) => event.messageTokens[0]); + expect(infoMessages).to.include('Enabled deletion protection for stack "service-dev"'); + }); + + it('should wrap AWS failures', async () => { + serverless.service.provider.deletionProtection = true; + const awsError = Object.assign( + new Error('User is not authorized to perform: cloudformation:UpdateTerminationProtection'), + { name: 'AccessDenied', $metadata: { httpStatusCode: 403 } } + ); + send.rejects(awsError); + + await expect(awsDeletionProtection.postDeploy()) + .to.eventually.be.rejectedWith(ServerlessError) + .and.satisfy((error) => { + expect(error.code).to.equal('AWS_CLOUDFORMATION_DELETION_PROTECTION_UPDATE_FAILED'); + expect(error.message).to.include('service-dev'); + expect(error.message).to.include( + 'User is not authorized to perform: cloudformation:UpdateTerminationProtection' + ); + expect(error.cause).to.equal(awsError); + return true; + }); + }); + + it('should throw on invalid configuration before calling AWS', async () => { + serverless.service.provider.deletionProtection = { stages: [] }; + + await expect(awsDeletionProtection.postDeploy()) + .to.eventually.be.rejectedWith(ServerlessError) + .and.have.property('code', 'INVALID_DELETION_PROTECTION_CONFIG'); + expect(awsDeletionProtection.getCloudFormationClient).to.not.have.been.called; + }); + }); + + describe('#getCloudFormationClient()', () => { + it('should reuse the client promise', async () => { + const first = await awsDeletionProtection.getCloudFormationClient(); + const second = await awsDeletionProtection.getCloudFormationClient(); + expect(first).to.equal(second); + expect(provider.getAwsSdkV3Config).to.have.been.calledOnce; + }); + }); +}); diff --git a/test/unit/lib/plugins/aws/deploy/index.test.js b/test/unit/lib/plugins/aws/deploy/index.test.js index 0e76178fad..ad25f02ef0 100644 --- a/test/unit/lib/plugins/aws/deploy/index.test.js +++ b/test/unit/lib/plugins/aws/deploy/index.test.js @@ -31,6 +31,204 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { ({ service, method: sendMethod }) => service === 'CloudFormation' && sendMethod === method ); + describe('deletion protection', () => { + const emptyChangeSetDescription = { + ChangeSetName: 'new-service-dev-change-set', + ChangeSetId: 'some-change-set-id', + StackName: 'new-service-dev', + Status: 'FAILED', + StatusReason: 'No updates are to be performed.', + }; + + async function deployWithDeletionProtection(deletionProtection, options = {}) { + const providerConfig = { + deploymentMethod: options.deploymentMethod || 'direct', + ...options.provider, + }; + if (deletionProtection !== undefined) providerConfig.deletionProtection = deletionProtection; + + const describeStacksStub = options.stackExists + ? sinon.stub().resolves({ Stacks: [{}] }) + : sinon + .stub() + .onFirstCall() + .throws(createCloudFormationValidationError('stack does not exist')) + .onSecondCall() + .resolves({ Stacks: [{}] }); + const updateTerminationProtectionStub = sinon.stub().resolves({}); + + const { awsSdkV3Stub } = await runServerless({ + fixture: 'function', + command: 'deploy', + awsSdkV3StubMap: { + ...baseAwsSdkV3StubMap, + ECR: { + describeRepositories: sinon.stub().throws({ + providerError: { code: 'RepositoryNotFoundException' }, + }), + }, + S3: { + deleteObjects: {}, + listObjectsV2: { Contents: [] }, + upload: {}, + headBucket: {}, + ...options.s3, + }, + CloudFormation: { + describeStacks: describeStacksStub, + createStack: {}, + updateStack: {}, + updateTerminationProtection: updateTerminationProtectionStub, + describeStackEvents: { + StackEvents: [ + { + EventId: '1e2f3g4h', + StackName: 'new-service-dev', + LogicalResourceId: 'new-service-dev', + ResourceType: 'AWS::CloudFormation::Stack', + Timestamp: new Date(), + ResourceStatus: options.stackExists ? 'UPDATE_COMPLETE' : 'CREATE_COMPLETE', + }, + ], + }, + describeStackResource: { + StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, + }, + validateTemplate: {}, + listStackResources: {}, + ...options.cloudFormation, + }, + }, + configExt: { + service: 'new-service', + provider: providerConfig, + }, + }); + + return { awsSdkV3Stub, updateTerminationProtectionStub }; + } + + for (const [description, deletionProtection, expected] of [ + ['enables deletion protection when configured to true', true, true], + ['disables deletion protection when configured to false', false, false], + [ + 'enables deletion protection when the current stage is listed', + { stages: ['dev', 'prod'] }, + true, + ], + [ + 'disables deletion protection when the current stage is not listed', + { stages: ['prod'] }, + false, + ], + ]) { + it(description, async () => { + const { awsSdkV3Stub, updateTerminationProtectionStub } = + await deployWithDeletionProtection(deletionProtection); + + expect(updateTerminationProtectionStub).to.be.calledOnce; + expect( + getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')[0].input + ).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: expected, + }); + }); + } + + it('does not manage deletion protection when it is not configured', async () => { + const { awsSdkV3Stub, updateTerminationProtectionStub } = + await deployWithDeletionProtection(undefined); + + expect(updateTerminationProtectionStub).not.to.be.called; + expect(getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')).to.be.empty; + }); + + it('disables deletion protection on an existing stack when configured to false', async () => { + const { awsSdkV3Stub, updateTerminationProtectionStub } = await deployWithDeletionProtection( + false, + { stackExists: true } + ); + + expect(updateTerminationProtectionStub).to.be.calledOnce; + expect( + getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')[0].input + ).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: false, + }); + }); + + it('reconciles deletion protection when the direct update is a no-op', async () => { + const { updateTerminationProtectionStub } = await deployWithDeletionProtection(true, { + stackExists: true, + cloudFormation: { + updateStack: () => { + throw createCloudFormationValidationError('No updates are to be performed.'); + }, + }, + }); + + expect(updateTerminationProtectionStub).to.be.calledOnce; + expect(updateTerminationProtectionStub.firstCall.args[0]).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: true, + }); + }); + + it('reconciles deletion protection with an empty change set', async () => { + const executeChangeSetStub = sinon.stub().resolves({}); + const { updateTerminationProtectionStub } = await deployWithDeletionProtection(false, { + stackExists: true, + deploymentMethod: 'changesets', + s3: { + listObjectsV2: sinon + .stub() + .onFirstCall() + .resolves({ Contents: [] }) + .onSecondCall() + .callsFake((params) => ({ + Contents: [{ Key: `${params.Prefix}/compiled-cloudformation-template.json` }], + })), + }, + cloudFormation: { + createChangeSet: {}, + executeChangeSet: executeChangeSetStub, + deleteChangeSet: {}, + describeChangeSet: emptyChangeSetDescription, + }, + }); + + expect(executeChangeSetStub).not.to.be.called; + expect(updateTerminationProtectionStub).to.be.calledOnce; + expect(updateTerminationProtectionStub.firstCall.args[0]).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: false, + }); + }); + + it('does not protect a stack left in REVIEW_IN_PROGRESS by an empty create change set', async () => { + const createChangeSetStub = sinon.stub().resolves({}); + const executeChangeSetStub = sinon.stub().resolves({}); + const { updateTerminationProtectionStub } = await deployWithDeletionProtection(true, { + deploymentMethod: 'changesets', + provider: { deploymentBucket: 'existing-s3-bucket' }, + s3: { headBucket: () => ({ BucketRegion: 'us-east-1' }) }, + cloudFormation: { + createChangeSet: createChangeSetStub, + executeChangeSet: executeChangeSetStub, + deleteChangeSet: {}, + describeChangeSet: emptyChangeSetDescription, + }, + }); + + expect(createChangeSetStub).to.be.calledOnce; + expect(createChangeSetStub.getCall(0).args[0].ChangeSetType).to.equal('CREATE'); + expect(executeChangeSetStub).not.to.be.called; + expect(updateTerminationProtectionStub).not.to.be.called; + }); + }); + describe('with direct create/update calls', () => { it('with nonexistent stack - first deploy', async () => { const describeStacksStub = sinon diff --git a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js index 65738b4bb3..336618d8e6 100644 --- a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js +++ b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js @@ -762,9 +762,7 @@ describe('checkForChanges', () => { { Metadata: { filesha256: 'remote-hash-cf-template' } }, { Metadata: { filesha256: 'remote-hash-zip-file-1' } }, { - Metadata: { - /* no filesha256 available */ - }, + Metadata: {/* no filesha256 available */}, }, // will be translated to '' ]; @@ -1301,6 +1299,7 @@ const runCheckForChanges = async ({ options, awsSdkV3StubMap, awsSdkV3StubMapOverrides, + lastLifecycleHookName = 'aws:deploy:deploy:checkForChanges', } = {}) => { let serverless; const getServerless = () => serverless; @@ -1311,7 +1310,7 @@ const runCheckForChanges = async ({ const runOptions = { command: 'deploy', options, - lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', + lastLifecycleHookName: lastLifecycleHookName || undefined, env: { AWS_CONTAINER_CREDENTIALS_FULL_URI: 'ignore' }, hooks: { beforeInstanceInit: (serverlessInstance) => (serverless = serverlessInstance), @@ -1364,6 +1363,31 @@ describe('test/unit/lib/plugins/aws/deploy/lib/checkForChanges.test.js', () => { expect(sentMethods).to.include('getFunction'); }); + it('should still reconcile deletion protection when the deployment is skipped', async () => { + const updateTerminationProtectionStub = sandbox.stub().resolves({}); + + const { serverless, awsSdkV3Stub } = await runCheckForChanges({ + configExt: { provider: { deletionProtection: true } }, + lastLifecycleHookName: null, + awsSdkV3StubMapOverrides: { + CloudFormation: { + updateTerminationProtection: updateTerminationProtectionStub, + listStackResources: {}, + }, + }, + }); + + expect(serverless.service.provider.shouldNotDeploy).to.equal(true); + const sentMethods = awsSdkV3Stub.sends.map(({ method }) => method); + expect(sentMethods).to.not.include('updateStack'); + expect(sentMethods).to.not.include('createChangeSet'); + expect(updateTerminationProtectionStub).to.have.been.calledOnce; + expect(updateTerminationProtectionStub.firstCall.args[0]).to.deep.equal({ + StackName: `${checkForChangesServiceName}-dev`, + EnableTerminationProtection: true, + }); + }); + it('should deploy with --force option', async () => { const { serverless, awsSdkV3Stub } = await runCheckForChanges({ options: { force: true } }); diff --git a/test/unit/lib/plugins/aws/provider.test.js b/test/unit/lib/plugins/aws/provider.test.js index 2cd6efced8..9f931ddfda 100644 --- a/test/unit/lib/plugins/aws/provider.test.js +++ b/test/unit/lib/plugins/aws/provider.test.js @@ -279,6 +279,84 @@ describe('AwsProvider', () => { }); }); + describe('deletionProtection validation', () => { + for (const [description, deletionProtection] of [ + ['boolean form', true], + ['stage list form', { stages: ['prod'] }], + ]) { + it(`accepts ${description}`, async () => { + await runServerless({ + fixture: 'function', + command: 'print', + configExt: { + provider: { + deletionProtection, + }, + }, + }); + }); + } + + for (const [description, deletionProtection, message] of [ + ['empty stages', { stages: [] }, 'must NOT have fewer than 1 items'], + ['enabled property', { enabled: true }, 'unrecognized property'], + ]) { + it(`rejects ${description}`, async () => { + await expect( + runServerless({ + fixture: 'function', + command: 'print', + configExt: { + provider: { + deletionProtection, + }, + }, + }) + ).to.eventually.be.rejectedWith(message); + }); + } + + for (const [description, deletionProtection] of [ + ['stage array', ['prod']], + ['stage string', 'prod'], + ]) { + it(`rejects ${description}`, async () => { + await expect( + runServerless({ + fixture: 'function', + command: 'print', + configExt: { + provider: { + deletionProtection, + }, + }, + }) + ).to.eventually.be.rejected.and.have.property( + 'code', + 'INVALID_NON_SCHEMA_COMPLIANT_CONFIGURATION' + ); + }); + } + + for (const [description, deletionProtection, expected] of [ + ['a string boolean', 'true', true], + ['a single stage string', { stages: 'prod' }, { stages: ['prod'] }], + ]) { + it(`coerces ${description}`, async () => { + const { serverless } = await runServerless({ + fixture: 'function', + command: 'print', + configExt: { + provider: { + deletionProtection, + }, + }, + }); + expect(serverless.service.provider.deletionProtection).to.deep.equal(expected); + }); + } + }); + describe('deploymentBucket configuration', () => { it('should do nothing if not defined', () => { serverless.service.provider.deploymentBucket = undefined; diff --git a/test/unit/lib/plugins/aws/remove/index.test.js b/test/unit/lib/plugins/aws/remove/index.test.js index d41dff356f..5406bbc6b8 100644 --- a/test/unit/lib/plugins/aws/remove/index.test.js +++ b/test/unit/lib/plugins/aws/remove/index.test.js @@ -36,6 +36,7 @@ describe('test/unit/lib/plugins/aws/remove/index.test.js', () => { headBucket: {}, }, CloudFormation: { + describeStacks: { Stacks: [{ EnableTerminationProtection: false }] }, describeStackEvents: describeStackEventsStub, deleteStack: deleteStackStub, describeStackResource: { StackResourceDetail: { PhysicalResourceId: 'resource-id' } }, @@ -258,6 +259,51 @@ describe('test/unit/lib/plugins/aws/remove/index.test.js', () => { ).to.equal(cloudFormationSends.find(({ method }) => method === 'deleteStack').client); }); + it('fails before cleanup when the stack has deletion protection enabled', async () => { + await expect( + runServerless({ + fixture: 'function', + command: 'remove', + awsSdkV3StubMap: { + ...awsSdkV3StubMap, + CloudFormation: { + ...awsSdkV3StubMap.CloudFormation, + describeStacks: { Stacks: [{ EnableTerminationProtection: true }] }, + }, + }, + }) + ).to.eventually.have.been.rejected.and.have.property( + 'code', + 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' + ); + + expect(deleteObjectsStub).not.to.be.called; + expect(deleteStackStub).not.to.be.called; + expect(deleteRepositoryStub).not.to.be.called; + }); + + it('proceeds with removal when deletion protection cannot be checked', async () => { + await runServerless({ + fixture: 'function', + command: 'remove', + awsSdkV3StubMap: { + ...awsSdkV3StubMap, + CloudFormation: { + ...awsSdkV3StubMap.CloudFormation, + describeStacks: () => { + throw Object.assign( + new Error('User is not authorized to perform: cloudformation:DescribeStacks'), + { name: 'AccessDenied', $metadata: { httpStatusCode: 403 } } + ); + }, + }, + }, + }); + + expect(deleteObjectsStub).to.be.calledOnce; + expect(deleteStackStub).to.be.calledOnce; + }); + it('executes expected operations during removal when repository cannot be accessed due to denied access', async () => { describeRepositoriesStub.throws({ providerError: { code: 'AccessDeniedException' } }); diff --git a/test/unit/lib/plugins/aws/remove/lib/stack.test.js b/test/unit/lib/plugins/aws/remove/lib/stack.test.js index 24639d290a..114e83b3c6 100644 --- a/test/unit/lib/plugins/aws/remove/lib/stack.test.js +++ b/test/unit/lib/plugins/aws/remove/lib/stack.test.js @@ -2,7 +2,12 @@ const expect = require('chai').expect; const sinon = require('sinon'); -const { CloudFormationClient, DeleteStackCommand } = require('@aws-sdk/client-cloudformation'); +const logEmitter = require('log/lib/emitter'); +const { + CloudFormationClient, + DeleteStackCommand, + DescribeStacksCommand, +} = require('@aws-sdk/client-cloudformation'); const removeStack = require('../../../../../../../lib/plugins/aws/remove/lib/stack'); describe('removeStack', () => { @@ -33,6 +38,90 @@ describe('removeStack', () => { CloudFormationClient.prototype.send.restore(); }); + describe('#ensureStackIsNotDeletionProtected()', () => { + it('passes when the stack is not deletion protected', async () => { + removeStackStub.resolves({ Stacks: [{ EnableTerminationProtection: false }] }); + const context = createRemoveStackContext(); + + await context.ensureStackIsNotDeletionProtected(); + + expect(removeStackStub).to.have.been.calledOnce; + expect(removeStackStub.firstCall.args[0]).to.be.instanceOf(DescribeStacksCommand); + expect(removeStackStub.firstCall.args[0].input).to.deep.equal({ StackName: stackName }); + }); + + it('fails when the stack is deletion protected', async () => { + removeStackStub.resolves({ Stacks: [{ EnableTerminationProtection: true }] }); + const context = createRemoveStackContext(); + + await expect(context.ensureStackIsNotDeletionProtected()) + .to.eventually.be.rejected.and.have.property( + 'code', + 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' + ) + .and.satisfy(() => true); + await expect( + context.ensureStackIsNotDeletionProtected() + ).to.eventually.be.rejected.and.satisfy((error) => { + expect(error.message).to.include(`Cannot remove stack "${stackName}"`); + expect(error.message).to.include('provider.deletionProtection.stages'); + expect(error.message).to.include( + `update-termination-protection --no-enable-termination-protection --stack-name ${stackName}` + ); + return true; + }); + }); + + it('passes with a warning when the stack cannot be described', async () => { + removeStackStub.rejects( + Object.assign( + new Error('User is not authorized to perform: cloudformation:DescribeStacks'), + { + name: 'AccessDenied', + $metadata: { httpStatusCode: 403 }, + } + ) + ); + const context = createRemoveStackContext(); + const logEvents = []; + const listener = (event) => logEvents.push(event); + logEmitter.on('log', listener); + + try { + await context.ensureStackIsNotDeletionProtected(); + } finally { + logEmitter.off('log', listener); + } + + const warnings = logEvents + .filter((event) => event.logger.level === 'warning') + .map((event) => event.messageTokens[0]); + expect(warnings).to.have.lengthOf(1); + expect(warnings[0]).to.include(`Could not check whether stack "${stackName}"`); + expect(warnings[0]).to.include( + 'User is not authorized to perform: cloudformation:DescribeStacks' + ); + }); + + it('passes when the stack lookup returns no stacks', async () => { + removeStackStub.resolves({ Stacks: [] }); + const context = createRemoveStackContext(); + + await context.ensureStackIsNotDeletionProtected(); + }); + + it('passes when the stack does not exist', async () => { + removeStackStub.throws( + Object.assign(new Error('Stack with id removeStack-dev does not exist'), { + name: 'ValidationError', + }) + ); + const context = createRemoveStackContext(); + + await context.ensureStackIsNotDeletionProtected(); + }); + }); + describe('#remove()', () => { it('should remove a stack', async () => { const context = createRemoveStackContext(); diff --git a/test/unit/lib/plugins/aws/rollback.test.js b/test/unit/lib/plugins/aws/rollback.test.js index 909c745c7a..919d3b91b4 100644 --- a/test/unit/lib/plugins/aws/rollback.test.js +++ b/test/unit/lib/plugins/aws/rollback.test.js @@ -521,4 +521,62 @@ describe('test/unit/lib/plugins/aws/rollback.test.js', () => { }) ).to.eventually.be.rejected.and.have.property('code', 'AWS_S3_LIST_OBJECTS_V2_ACCESS_DENIED'); }); + + it('does not touch deletion protection', async () => { + const updateTerminationProtectionStub = sinon.stub().resolves({}); + const updateStackStub = sinon.stub().resolves({}); + const deploymentDirectory = '1476779096930-2016-10-18T08:24:56.930Z'; + const { awsSdkV3Stub } = await runServerless({ + fixture: 'function', + command: 'rollback', + options: { timestamp: '1476779096930' }, + configExt: { provider: { deploymentMethod: 'direct', deletionProtection: true } }, + awsSdkV3StubMap: { + CloudFormation: { + describeStackResource: { + StackResourceDetail: { PhysicalResourceId: 'deployment-bucket' }, + }, + updateStack: updateStackStub, + updateTerminationProtection: updateTerminationProtectionStub, + describeStackEvents: { + StackEvents: [ + { + EventId: '1e2f3g4h', + StackName: 'service-dev', + LogicalResourceId: 'service-dev', + ResourceType: 'AWS::CloudFormation::Stack', + Timestamp: new Date(), + ResourceStatus: 'UPDATE_COMPLETE', + }, + ], + }, + }, + STS: { + getCallerIdentity: { + ResponseMetadata: { RequestId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' }, + UserId: 'XXXXXXXXXXXXXXXXXXXXX', + Account: '999999999999', + Arn: 'arn:aws:iam::999999999999:user/test', + }, + }, + S3: { + headObject: () => {}, + headBucket: () => {}, + listObjectsV2: ({ Prefix }) => ({ + Contents: [ + { Key: `${Prefix}${deploymentDirectory}/compiled-cloudformation-template.json` }, + { Key: `${Prefix}${deploymentDirectory}/service.zip` }, + ], + }), + getObject: { Body: '{}' }, + }, + }, + }); + + expect(updateStackStub).to.have.been.calledOnce; + const sentMethods = awsSdkV3Stub.sends.map(({ method }) => method); + expect(sentMethods).to.include('updateStack'); + expect(sentMethods).to.not.include('updateTerminationProtection'); + expect(updateTerminationProtectionStub).not.to.have.been.called; + }); }); diff --git a/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js b/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js index ddc7e1be6a..34337c8a1f 100644 --- a/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js +++ b/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js @@ -316,6 +316,7 @@ describe('test/unit/test-lib/configure-aws-sdk-v3-stub.test.js', () => { deleteChangeSet: {}, getTemplate: { TemplateBody: '{}' }, setStackPolicy: {}, + updateTerminationProtection: {}, describeStackEvents: { StackEvents: [] }, describeStacks: { Stacks: [] }, listStackResources: { StackResourceSummaries: [] }, @@ -332,6 +333,7 @@ describe('test/unit/test-lib/configure-aws-sdk-v3-stub.test.js', () => { DeleteChangeSetCommand, GetTemplateCommand, SetStackPolicyCommand, + UpdateTerminationProtectionCommand, DescribeStackEventsCommand, DescribeStacksCommand, ListStackResourcesCommand, @@ -356,6 +358,7 @@ describe('test/unit/test-lib/configure-aws-sdk-v3-stub.test.js', () => { ); await cloudFormation.send(new GetTemplateCommand({ StackName: 'stack' })); await cloudFormation.send(new SetStackPolicyCommand({ StackName: 'stack' })); + await cloudFormation.send(new UpdateTerminationProtectionCommand({ StackName: 'stack' })); await cloudFormation.send(new DescribeStackEventsCommand({ StackName: 'stack' })); await cloudFormation.send(new DescribeStacksCommand({ StackName: 'stack' })); await cloudFormation.send(new ListStackResourcesCommand({ StackName: 'stack' })); @@ -370,6 +373,7 @@ describe('test/unit/test-lib/configure-aws-sdk-v3-stub.test.js', () => { 'CloudFormation.deleteChangeSet', 'CloudFormation.getTemplate', 'CloudFormation.setStackPolicy', + 'CloudFormation.updateTerminationProtection', 'CloudFormation.describeStackEvents', 'CloudFormation.describeStacks', 'CloudFormation.listStackResources', diff --git a/types/index.d.ts b/types/index.d.ts index 5afd2e3568..de681791b1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -89,6 +89,11 @@ export type AwsPruneFunctionVersions = | { number: number; }; +export type AwsDeletionProtection = + | boolean + | { + stages: Stage[]; + }; export type AwsHttpApiPayload = '1.0' | '2.0'; export type AwsApiGatewayApiKeys = ( | string @@ -912,6 +917,7 @@ export interface AWS { }; }; }; + deletionProtection?: AwsDeletionProtection; deploymentBucket?: | AwsS3BucketName | {