From 8aff55d7a6b651126db145d80f94c2372f8b77b9 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 4 Jul 2026 19:39:10 +0200 Subject: [PATCH 1/4] Add native support for deletion protection This replaces the https://github.com/serverless-guru/serverless-termination-protection plugin that is no longer maintain and makes it a first-party feature. --- docs/guides/deploying.md | 22 ++++ docs/guides/serverless.yml.md | 6 + lib/plugins/aws/deploy/index.js | 5 +- .../aws/lib/resolve-deletion-protection.js | 10 ++ lib/plugins/aws/lib/update-stack.js | 24 ++++ lib/plugins/aws/provider.js | 17 +++ lib/plugins/aws/remove/index.js | 3 +- lib/plugins/aws/remove/lib/stack.js | 29 ++++- test/lib/configure-aws-sdk-v3-stub.js | 1 + .../unit/lib/plugins/aws/deploy/index.test.js | 118 ++++++++++++++++++ test/unit/lib/plugins/aws/provider.test.js | 38 ++++++ .../unit/lib/plugins/aws/remove/index.test.js | 24 ++++ .../lib/plugins/aws/remove/lib/stack.test.js | 40 +++++- .../configure-aws-sdk-v3-stub.test.js | 4 + types/index.d.ts | 5 + 15 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 lib/plugins/aws/lib/resolve-deletion-protection.js diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index d8055c82e..680579223 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -44,6 +44,28 @@ provider: deploymentMethod: direct ``` +### Deletion protection + +You can enable CloudFormation termination protection for a service stack with `provider.deletionProtection`: + +```yaml +provider: + name: aws + deletionProtection: true +``` + +To enable it only for specific stages, list those stages: + +```yaml +provider: + name: aws + deletionProtection: + stages: + - prod +``` + +When deletion protection is enabled, `osls remove` or deleting the CloudFormation stack will fail. Disable `provider.deletionProtection` and deploy the service before removing it. + ### 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 fee656f4a..e7462a82a 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -82,6 +82,12 @@ provider: key: value # Method used for CloudFormation deployments: 'changesets' or 'direct' (default: changesets) deploymentMethod: direct + # Enable CloudFormation termination protection for the stack. + # To enable only for specific stages, use: + # deletionProtection: + # stages: + # - prod + deletionProtection: true # 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/deploy/index.js b/lib/plugins/aws/deploy/index.js index 11a11e14d..67b0957a6 100644 --- a/lib/plugins/aws/deploy/index.js +++ b/lib/plugins/aws/deploy/index.js @@ -132,7 +132,10 @@ class AwsDeploy { 'aws:deploy:deploy:checkForChanges': async () => { await this.ensureValidBucketExists(); await this.checkForChanges(); - if (this.serverless.service.provider.shouldNotDeploy) return; + if (this.serverless.service.provider.shouldNotDeploy) { + await this.reconcileDeletionProtection(); + return; + } if (this.state.console) { throw new ServerlessError( diff --git a/lib/plugins/aws/lib/resolve-deletion-protection.js b/lib/plugins/aws/lib/resolve-deletion-protection.js new file mode 100644 index 000000000..61bf9a829 --- /dev/null +++ b/lib/plugins/aws/lib/resolve-deletion-protection.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = (provider, stage) => { + const deletionProtection = provider.deletionProtection; + + if (deletionProtection == null) return undefined; + if (typeof deletionProtection === 'boolean') return deletionProtection; + + return deletionProtection.stages.includes(stage); +}; diff --git a/lib/plugins/aws/lib/update-stack.js b/lib/plugins/aws/lib/update-stack.js index 574ddca30..d5cef2a35 100644 --- a/lib/plugins/aws/lib/update-stack.js +++ b/lib/plugins/aws/lib/update-stack.js @@ -11,8 +11,10 @@ const { ExecuteChangeSetCommand, UpdateStackCommand, SetStackPolicyCommand, + UpdateTerminationProtectionCommand, } = require('@aws-sdk/client-cloudformation'); const { isCloudFormationNoUpdateError } = require('../../../aws/aws-sdk-v3-error'); +const resolveDeletionProtection = require('./resolve-deletion-protection'); function getCloudFormationClient(context) { context.cloudFormationClientPromise ||= context.provider @@ -22,6 +24,22 @@ function getCloudFormationClient(context) { } module.exports = { + async reconcileDeletionProtection() { + const enableDeletionProtection = resolveDeletionProtection( + this.serverless.service.provider, + this.provider.getStage() + ); + if (enableDeletionProtection == null) return; + + const cloudFormation = await getCloudFormationClient(this); + await cloudFormation.send( + new UpdateTerminationProtectionCommand({ + StackName: this.provider.naming.getStackName(), + EnableTerminationProtection: enableDeletionProtection, + }) + ); + }, + async createFallback() { this.createLater = false; progress.get('main').notice('Creating CloudFormation stack', { isMainEvent: true }); @@ -69,6 +87,7 @@ module.exports = { }) ); this.serverless.service.provider.deploymentWithEmptyChangeSet = true; + await this.reconcileDeletionProtection(); return false; } @@ -77,6 +96,7 @@ module.exports = { monitorCfData = changeSetDescription; } await this.monitorStack('create', monitorCfData); + await this.reconcileDeletionProtection(); return true; }, @@ -96,6 +116,7 @@ module.exports = { monitorCfData = await cloudFormation.send(new UpdateStackCommand(params)); } catch (e) { if (isCloudFormationNoUpdateError(e)) { + await this.reconcileDeletionProtection(); return false; } throw e; @@ -137,6 +158,7 @@ module.exports = { }) ); this.serverless.service.provider.deploymentWithEmptyChangeSet = true; + await this.reconcileDeletionProtection(); return false; } @@ -169,6 +191,8 @@ module.exports = { ); } + await this.reconcileDeletionProtection(); + return true; }, diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index 62240f097..968bf4659 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -945,6 +945,23 @@ class AwsProvider { ], }, deploymentPrefix: { type: 'string' }, + deletionProtection: { + anyOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + stages: { + type: 'array', + minItems: 1, + items: { $ref: '#/definitions/stage' }, + }, + }, + required: ['stages'], + additionalProperties: false, + }, + ], + }, disableRollback: { type: 'boolean' }, endpointType: { anyOf: ['REGIONAL', 'EDGE', 'PRIVATE'].map(caseInsensitive), diff --git a/lib/plugins/aws/remove/index.js b/lib/plugins/aws/remove/index.js index 6a3df220c..b0f12ddb5 100644 --- a/lib/plugins/aws/remove/index.js +++ b/lib/plugins/aws/remove/index.js @@ -49,8 +49,9 @@ class AwsRemove { } }, 'remove:remove': async () => { - const doesEcrRepositoryExistPromise = this.checkIfEcrRepositoryExists(); await this.validate(); + await this.ensureStackIsNotDeletionProtected(); + const doesEcrRepositoryExistPromise = this.checkIfEcrRepositoryExists(); 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 c5c61005c..5a7de8e36 100644 --- a/lib/plugins/aws/remove/lib/stack.js +++ b/lib/plugins/aws/remove/lib/stack.js @@ -1,6 +1,12 @@ '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 { isCloudFormationMissingStackError } = require('../../../../aws/aws-sdk-v3-error'); function getCloudFormationClient(context) { context.cloudFormationClientPromise ||= context.provider @@ -10,6 +16,27 @@ 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; + throw error; + } + + if (!stack || !stack.EnableTerminationProtection) return; + + throw new ServerlessError( + `Cannot remove stack "${stackName}" because deletion protection is enabled. Disable provider.deletionProtection and deploy the service before removing it.`, + 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' + ); + }, + async remove() { const stackName = this.provider.naming.getStackName(); const params = { diff --git a/test/lib/configure-aws-sdk-v3-stub.js b/test/lib/configure-aws-sdk-v3-stub.js index 1fdba39f6..0fa59ee39 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/deploy/index.test.js b/test/unit/lib/plugins/aws/deploy/index.test.js index 0e76178fa..d8b408253 100644 --- a/test/unit/lib/plugins/aws/deploy/index.test.js +++ b/test/unit/lib/plugins/aws/deploy/index.test.js @@ -3,6 +3,7 @@ const sinon = require('sinon'); const runServerless = require('../../../../../utils/run-serverless'); +const AwsDeploy = require('../../../../../../lib/plugins/aws/deploy'); const expect = require('chai').expect; @@ -31,6 +32,123 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { ({ service, method: sendMethod }) => service === 'CloudFormation' && sendMethod === method ); + describe('deletion protection', () => { + const deployWithDeletionProtection = async (deletionProtection) => { + const describeStacksStub = 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: {}, + }, + 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: 'CREATE_COMPLETE', + }, + ], + }, + describeStackResource: { + StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, + }, + validateTemplate: {}, + listStackResources: {}, + }, + }, + configExt: { + service: 'new-service', + provider: { + deploymentMethod: 'direct', + deletionProtection, + }, + }, + }); + + 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(updateTerminationProtectionStub.firstCall.args[0]).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: expected, + }); + expect(getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')[0].input).to.deep.equal( + { + StackName: 'new-service-dev', + EnableTerminationProtection: expected, + } + ); + }); + } + + it('reconciles deletion protection when no deployment is needed', async () => { + const provider = { + getStage: sinon.stub().returns('dev'), + getRegion: sinon.stub().returns('us-east-1'), + }; + const serverless = { + serviceDir: '', + service: { + service: 'service', + package: {}, + provider: { shouldNotDeploy: false }, + }, + getProvider: sinon.stub().withArgs('aws').returns(provider), + }; + const deploy = new AwsDeploy(serverless, {}); + deploy.ensureValidBucketExists = sinon.stub().resolves(); + deploy.checkForChanges = sinon.stub().callsFake(async () => { + serverless.service.provider.shouldNotDeploy = true; + }); + deploy.reconcileDeletionProtection = sinon.stub().resolves(); + + await deploy.hooks['aws:deploy:deploy:checkForChanges'](); + + expect(deploy.reconcileDeletionProtection).to.be.calledOnceWithExactly(); + }); + }); + describe('with direct create/update calls', () => { it('with nonexistent stack - first deploy', async () => { const describeStacksStub = sinon diff --git a/test/unit/lib/plugins/aws/provider.test.js b/test/unit/lib/plugins/aws/provider.test.js index 2cd6efced..55d70c7ee 100644 --- a/test/unit/lib/plugins/aws/provider.test.js +++ b/test/unit/lib/plugins/aws/provider.test.js @@ -279,6 +279,44 @@ 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); + }); + } + }); + 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 d41dff356..8c2dea478 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,29 @@ 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(describeRepositoriesStub).not.to.be.called; + }); + 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 24639d290..b4d57d643 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,11 @@ const expect = require('chai').expect; const sinon = require('sinon'); -const { CloudFormationClient, DeleteStackCommand } = require('@aws-sdk/client-cloudformation'); +const { + CloudFormationClient, + DeleteStackCommand, + DescribeStacksCommand, +} = require('@aws-sdk/client-cloudformation'); const removeStack = require('../../../../../../../lib/plugins/aws/remove/lib/stack'); describe('removeStack', () => { @@ -33,6 +37,40 @@ 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' + ); + }); + + 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/test-lib/configure-aws-sdk-v3-stub.test.js b/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js index ddc7e1be6..34337c8a1 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 da76b0815..8d612e340 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -925,6 +925,11 @@ export interface AWS { tags?: AwsResourceTags; }; deploymentPrefix?: string; + deletionProtection?: + | boolean + | { + stages: Stage[]; + }; disableRollback?: boolean; endpointType?: string; environment?: AwsLambdaEnvironment; From e380b776c82be4bdce05852b9da248de5fe0ddb6 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 4 Jul 2026 19:43:14 +0200 Subject: [PATCH 2/4] Fix deletion protection formatting --- .../unit/lib/plugins/aws/deploy/index.test.js | 18 +++++++++------ .../lib/plugins/aws/remove/lib/stack.test.js | 4 +++- types/index.d.ts | 22 +++---------------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/test/unit/lib/plugins/aws/deploy/index.test.js b/test/unit/lib/plugins/aws/deploy/index.test.js index d8b408253..a32137796 100644 --- a/test/unit/lib/plugins/aws/deploy/index.test.js +++ b/test/unit/lib/plugins/aws/deploy/index.test.js @@ -102,7 +102,11 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { { stages: ['dev', 'prod'] }, true, ], - ['disables deletion protection when the current stage is not listed', { stages: ['prod'] }, false], + [ + 'disables deletion protection when the current stage is not listed', + { stages: ['prod'] }, + false, + ], ]) { it(description, async () => { const { awsSdkV3Stub, updateTerminationProtectionStub } = @@ -113,12 +117,12 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { StackName: 'new-service-dev', EnableTerminationProtection: expected, }); - expect(getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')[0].input).to.deep.equal( - { - StackName: 'new-service-dev', - EnableTerminationProtection: expected, - } - ); + expect( + getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')[0].input + ).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: expected, + }); }); } 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 b4d57d643..2bec4a783 100644 --- a/test/unit/lib/plugins/aws/remove/lib/stack.test.js +++ b/test/unit/lib/plugins/aws/remove/lib/stack.test.js @@ -53,7 +53,9 @@ describe('removeStack', () => { removeStackStub.resolves({ Stacks: [{ EnableTerminationProtection: true }] }); const context = createRemoveStackContext(); - await expect(context.ensureStackIsNotDeletionProtected()).to.eventually.be.rejected.and.have.property( + await expect( + context.ensureStackIsNotDeletionProtected() + ).to.eventually.be.rejected.and.have.property( 'code', 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' ); diff --git a/types/index.d.ts b/types/index.d.ts index 8d612e340..54442fc39 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,13 +3,7 @@ export type AwsArnString = string; export type ErrorCode = string; export type AwsCfFunction = - | AwsCfImport - | AwsCfJoin - | AwsCfGetAtt - | AwsCfRef - | AwsCfSub - | AwsCfBase64 - | AwsCfToJsonString; + AwsCfImport | AwsCfJoin | AwsCfGetAtt | AwsCfRef | AwsCfSub | AwsCfBase64 | AwsCfToJsonString; export type AwsCfInstruction = string | AwsCfFunction; export type AwsArn = AwsArnString | AwsCfFunction; export type FunctionName = string; @@ -242,14 +236,7 @@ export interface AWS { headers?: string[]; maxAge?: number; methods?: ( - | 'GET' - | 'POST' - | 'PUT' - | 'PATCH' - | 'OPTIONS' - | 'HEAD' - | 'DELETE' - | 'ANY' + 'GET' | 'POST' | 'PUT' | 'PATCH' | 'OPTIONS' | 'HEAD' | 'DELETE' | 'ANY' )[]; origin?: string; origins?: string[]; @@ -630,10 +617,7 @@ export interface AWS { [k: string]: unknown; }; eventType?: - | 'viewer-request' - | 'origin-request' - | 'origin-response' - | 'viewer-response'; + 'viewer-request' | 'origin-request' | 'origin-response' | 'viewer-response'; isDefaultOrigin?: boolean; includeBody?: boolean; origin?: From adb0b267c1b81980a43916a65b27d188ea7ec225 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 4 Jul 2026 19:47:40 +0200 Subject: [PATCH 3/4] Clarify disabling deletion protection --- docs/guides/deploying.md | 4 +++- lib/plugins/aws/remove/lib/stack.js | 2 +- test/unit/lib/plugins/aws/remove/lib/stack.test.js | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 680579223..ba9c65500 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -64,7 +64,9 @@ provider: - prod ``` -When deletion protection is enabled, `osls remove` or deleting the CloudFormation stack will fail. Disable `provider.deletionProtection` and deploy the service before removing it. +When deletion protection is enabled, `osls remove` or deleting the CloudFormation stack will fail. To disable it, set `provider.deletionProtection` to `false` and deploy the service before removing it. + +Removing `provider.deletionProtection` from `serverless.yml` does not disable termination protection on an existing stack. It only stops osls from managing the setting. ### Tips diff --git a/lib/plugins/aws/remove/lib/stack.js b/lib/plugins/aws/remove/lib/stack.js index 5a7de8e36..b83b13573 100644 --- a/lib/plugins/aws/remove/lib/stack.js +++ b/lib/plugins/aws/remove/lib/stack.js @@ -32,7 +32,7 @@ module.exports = { if (!stack || !stack.EnableTerminationProtection) return; throw new ServerlessError( - `Cannot remove stack "${stackName}" because deletion protection is enabled. Disable provider.deletionProtection and deploy the service before removing it.`, + `Cannot remove stack "${stackName}" because deletion protection is enabled. Set provider.deletionProtection to false and deploy the service before removing it.`, 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' ); }, 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 2bec4a783..04bbe3592 100644 --- a/test/unit/lib/plugins/aws/remove/lib/stack.test.js +++ b/test/unit/lib/plugins/aws/remove/lib/stack.test.js @@ -55,10 +55,10 @@ describe('removeStack', () => { await expect( context.ensureStackIsNotDeletionProtected() - ).to.eventually.be.rejected.and.have.property( - 'code', - 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED' - ); + ).to.eventually.be.rejected.and.include({ + code: 'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED', + message: `Cannot remove stack "${stackName}" because deletion protection is enabled. Set provider.deletionProtection to false and deploy the service before removing it.`, + }); }); it('passes when the stack does not exist', async () => { From a99c15a19a297cc4f5a08b053ed9808cbf2b6230 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 4 Jul 2026 19:51:54 +0200 Subject: [PATCH 4/4] Add deletion protection regression tests --- .../unit/lib/plugins/aws/deploy/index.test.js | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/test/unit/lib/plugins/aws/deploy/index.test.js b/test/unit/lib/plugins/aws/deploy/index.test.js index a32137796..600dd3313 100644 --- a/test/unit/lib/plugins/aws/deploy/index.test.js +++ b/test/unit/lib/plugins/aws/deploy/index.test.js @@ -33,13 +33,20 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { ); describe('deletion protection', () => { - const deployWithDeletionProtection = async (deletionProtection) => { - const describeStacksStub = sinon - .stub() - .onFirstCall() - .throws(createCloudFormationValidationError('stack does not exist')) - .onSecondCall() - .resolves({ Stacks: [{}] }); + async function deployWithDeletionProtection(deletionProtection, options = {}) { + const providerConfig = { + deploymentMethod: 'direct', + }; + if (arguments.length > 0) 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({ @@ -71,7 +78,7 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { LogicalResourceId: 'new-service-dev', ResourceType: 'AWS::CloudFormation::Stack', Timestamp: new Date(), - ResourceStatus: 'CREATE_COMPLETE', + ResourceStatus: options.stackExists ? 'UPDATE_COMPLETE' : 'CREATE_COMPLETE', }, ], }, @@ -84,15 +91,12 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { }, configExt: { service: 'new-service', - provider: { - deploymentMethod: 'direct', - deletionProtection, - }, + provider: providerConfig, }, }); return { awsSdkV3Stub, updateTerminationProtectionStub }; - }; + } for (const [description, deletionProtection, expected] of [ ['enables deletion protection when configured to true', true, true], @@ -126,6 +130,33 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { }); } + it('does not manage deletion protection when it is not configured', async () => { + const { awsSdkV3Stub, updateTerminationProtectionStub } = + await deployWithDeletionProtection(); + + 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(updateTerminationProtectionStub.firstCall.args[0]).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: false, + }); + expect( + getCloudFormationSends(awsSdkV3Stub, 'updateTerminationProtection')[0].input + ).to.deep.equal({ + StackName: 'new-service-dev', + EnableTerminationProtection: false, + }); + }); + it('reconciles deletion protection when no deployment is needed', async () => { const provider = { getStage: sinon.stub().returns('dev'),