Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ 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. 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

- Use this in your CI/CD systems, as it is the safest method of deployment.
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/serverless.yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 4 additions & 1 deletion lib/plugins/aws/deploy/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions lib/plugins/aws/lib/resolve-deletion-protection.js
Original file line number Diff line number Diff line change
@@ -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);
};
24 changes: 24 additions & 0 deletions lib/plugins/aws/lib/update-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
Expand Down Expand Up @@ -69,6 +87,7 @@ module.exports = {
})
);
this.serverless.service.provider.deploymentWithEmptyChangeSet = true;
await this.reconcileDeletionProtection();
return false;
}

Expand All @@ -77,6 +96,7 @@ module.exports = {
monitorCfData = changeSetDescription;
}
await this.monitorStack('create', monitorCfData);
await this.reconcileDeletionProtection();
return true;
},

Expand All @@ -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;
Expand Down Expand Up @@ -137,6 +158,7 @@ module.exports = {
})
);
this.serverless.service.provider.deploymentWithEmptyChangeSet = true;
await this.reconcileDeletionProtection();
return false;
}

Expand Down Expand Up @@ -169,6 +191,8 @@ module.exports = {
);
}

await this.reconcileDeletionProtection();

return true;
},

Expand Down
17 changes: 17 additions & 0 deletions lib/plugins/aws/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion lib/plugins/aws/remove/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
29 changes: 28 additions & 1 deletion lib/plugins/aws/remove/lib/stack.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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. Set provider.deletionProtection to false and deploy the service before removing it.`,
'AWS_CLOUDFORMATION_DELETION_PROTECTION_ENABLED'
);
},

async remove() {
const stackName = this.provider.naming.getStackName();
const params = {
Expand Down
1 change: 1 addition & 0 deletions test/lib/configure-aws-sdk-v3-stub.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const serviceDefinitions = {
deleteChangeSet: 'DeleteChangeSetCommand',
getTemplate: 'GetTemplateCommand',
setStackPolicy: 'SetStackPolicyCommand',
updateTerminationProtection: 'UpdateTerminationProtectionCommand',
describeStackEvents: 'DescribeStackEventsCommand',
},
},
Expand Down
153 changes: 153 additions & 0 deletions test/unit/lib/plugins/aws/deploy/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -31,6 +32,158 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => {
({ service, method: sendMethod }) => service === 'CloudFormation' && sendMethod === method
);

describe('deletion protection', () => {
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({
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: options.stackExists ? 'UPDATE_COMPLETE' : 'CREATE_COMPLETE',
},
],
},
describeStackResource: {
StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' },
},
validateTemplate: {},
listStackResources: {},
},
},
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(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('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'),
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
Expand Down
Loading