Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add config to deploymentBucket to support S3 bucket versioning #9912

Merged
merged 11 commits into from
Oct 12, 2021
2 changes: 2 additions & 0 deletions docs/providers/aws/guide/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ The Serverless Framework translates all syntax in `serverless.yml` to a single A
- You can disable creation of default S3 bucket policy by setting `skipPolicySetup` under `deploymentBucket` config. It only applies to deployment bucket that is automatically created
by the Serverless Framework.

- You can enable versioning for the deployment bucket by setting `versioning` under `deploymentBucket` config to `true`.

Check out the [deploy command docs](../cli-reference/deploy.md) for all details and options.

- For information on multi-region deployments, [checkout this article](https://serverless.com/blog/build-multiregion-multimaster-application-dynamodb-global-tables).
Expand Down
1 change: 1 addition & 0 deletions docs/providers/aws/guide/serverless.yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ provider:
skipPolicySetup: false # Prevents creation of default bucket policy when framework creates the deployment bucket. Default is false
name: com.serverless.${self:provider.region}.deploys # Deployment bucket name. Default is generated by the framework
maxPreviousDeploymentArtifacts: 5 # On every deployment the framework prunes the bucket to remove artifacts older than this limit. The default is 5
versioning: false # enable bucket versioning. Default is false
serverSideEncryption: AES256 # server-side encryption method
sseKMSKeyId: arn:aws:kms:us-east-1:xxxxxxxxxxxx:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa # when using server-side encryption
sseCustomerAlgorithim: AES256 # when using server-side encryption and custom keys
Expand Down
11 changes: 11 additions & 0 deletions lib/plugins/aws/package/lib/generateCoreTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ module.exports = {
);
}

// enable S3 bucket versioning
if (deploymentBucketObject.versioning) {
this.serverless.service.provider.compiledCloudFormationTemplate.Resources[
deploymentBucketLogicalId
].Properties = {
VersioningConfiguration: {
Status: 'Enabled',
},
};
}

if (deploymentBucketObject.skipPolicySetup) {
const deploymentBucketPolicyLogicalId =
this.provider.naming.getDeploymentBucketPolicyLogicalId();
Expand Down
1 change: 1 addition & 0 deletions lib/plugins/aws/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,7 @@ class AwsProvider {
skipPolicySetup: { type: 'boolean' },
maxPreviousDeploymentArtifacts: { type: 'integer', minimum: 0 },
name: { $ref: '#/definitions/awsS3BucketName' },
versioning: { type: 'boolean' },
serverSideEncryption: { enum: ['AES256', 'aws:kms'] },
sseCustomerAlgorithim: { type: 'string' },
sseCustomerKey: { type: 'string' },
Expand Down
43 changes: 42 additions & 1 deletion lib/plugins/aws/remove/lib/bucket.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
});
},

async listObjects() {
async listObjectsV2() {
this.objectsInBucket = [];

legacy.log('Getting all objects in S3 bucket...');
Expand All @@ -33,6 +33,47 @@ module.exports = {
});
},

async listObjectVersions() {
this.objectsInBucket = [];

this.serverless.cli.log('Getting all objects in S3 bucket...');
const serviceStage = `${this.serverless.service.service}/${this.provider.getStage()}`;

const result = await this.provider.request('S3', 'listObjectVersions', {
Bucket: this.bucketName,
Prefix: `${this.provider.getDeploymentPrefix()}/${serviceStage}`,
});

if (result) {
if (result.Versions) {
result.Versions.forEach((object) => {
this.objectsInBucket.push({
Key: object.Key,
VersionId: object.VersionId,
});
});
}

if (result.DeleteMarkers) {
result.DeleteMarkers.forEach((object) => {
this.objectsInBucket.push({
Key: object.Key,
VersionId: object.VersionId,
});
});
}
}

return Promise.resolve();
mars-lan marked this conversation as resolved.
Show resolved Hide resolved
},

async listObjects() {
const deploymentBucketObject = this.serverless.service.provider.deploymentBucketObject;
return deploymentBucketObject && deploymentBucketObject.versioning
? this.listObjectVersions()
: this.listObjectsV2();
},

async deleteObjects() {
legacy.log('Removing objects in S3 bucket...');
if (this.objectsInBucket.length) {
Expand Down
21 changes: 21 additions & 0 deletions test/unit/lib/plugins/aws/package/lib/generateCoreTemplate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ describe('#generateCoreTemplate()', () => {
});
}));

it('should enable S3 bucket versioning if specified', async () => {
const { cfTemplate } = await runServerless({
config: {
service: 'irrelevant',
provider: {
name: 'aws',
deploymentBucket: {
versioning: true,
},
},
},
command: 'package',
});

expect(cfTemplate.Resources.ServerlessDeploymentBucket.Properties).to.deep.include({
VersioningConfiguration: {
Status: 'Enabled',
},
});
});

it('should add resource tags to the bucket if present', () =>
runServerless({
config: {
Expand Down
139 changes: 138 additions & 1 deletion test/unit/lib/plugins/aws/remove/lib/bucket.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
'use strict';

const expect = require('chai').expect;

const sinon = require('sinon');
const AwsProvider = require('../../../../../../../lib/plugins/aws/provider');
const AwsRemove = require('../../../../../../../lib/plugins/aws/remove/index');
const Serverless = require('../../../../../../../lib/Serverless');

const runServerless = require('../../../../../../utils/run-serverless');

describe('emptyS3Bucket', () => {
const options = {
stage: 'dev',
Expand Down Expand Up @@ -37,7 +40,7 @@ describe('emptyS3Bucket', () => {
});
});

describe('#listObjects()', () => {
describe('#listObjectsV2()', () => {
it('should resolve if no objects are present', () => {
const listObjectsStub = sinon.stub(awsRemove.provider, 'request').resolves();

Expand Down Expand Up @@ -80,6 +83,140 @@ describe('emptyS3Bucket', () => {
});
});

describe('#listObjectVersions()', () => {
const baseAwsRequestStubMap = {
STS: {
getCallerIdentity: {
ResponseMetadata: { RequestId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' },
UserId: 'XXXXXXXXXXXXXXXXXXXXX',
Account: '999999999999',
Arn: 'arn:aws:iam::999999999999:user/test',
},
},
ECR: {
describeRepositories: sinon.stub().throws({
providerError: { code: 'RepositoryNotFoundException' },
}),
},
CloudFormation: {
describeStacks: {},
describeStackEvents: {
StackEvents: [
{
EventId: '1e2f3g4h',
StackName: 'new-service-dev',
LogicalResourceId: 'new-service-dev',
ResourceType: 'AWS::CloudFormation::Stack',
Timestamp: new Date(),
ResourceStatus: 'DELETE_COMPLETE',
},
],
},
describeStackResource: {
StackResourceDetail: { PhysicalResourceId: 'deployment-bucket' },
},
deleteStack: {},
},
};

it('should resolve if no object versions are present', async () => {
const listObjectVersionsStub = sinon.stub().resolves();

await runServerless({
command: 'remove',
config: {
service: 'test-service',
provider: {
name: 'aws',
stage: 'dev',
region: 'us-east-1',
deploymentPrefix: 'serverless',
deploymentBucket: {
name: 'bucket',
versioning: true,
},
},
},
awsRequestStubMap: {
...baseAwsRequestStubMap,
S3: {
listObjectVersions: listObjectVersionsStub,
},
},
});

expect(
listObjectVersionsStub.calledWithExactly({
Bucket: 'bucket',
Prefix: 'serverless/test-service/dev',
})
).to.be.equal(true);
});

it('should push objects to the array if present', async () => {
const listObjectVersionsStub = sinon.stub().resolves({
Versions: [
{ Key: 'object1', VersionId: null },
{ Key: 'object2', VersionId: 'v1' },
],
DeleteMarkers: [{ Key: 'object3', VersionId: 'v2' }],
});

const deleteObjectsStub = sinon.stub().resolves({
Deleted: [
{ Key: 'object1', VersionId: null },
{ Key: 'object2', VersionId: 'v1' },
{ Key: 'object3', VersionId: 'v2' },
],
});

await runServerless({
command: 'remove',
config: {
service: 'test-service',
provider: {
name: 'aws',
stage: 'dev',
region: 'us-east-1',
deploymentPrefix: 'serverless',
deploymentBucket: {
name: 'bucket',
versioning: true,
},
},
},
awsRequestStubMap: {
...baseAwsRequestStubMap,
S3: {
listObjectVersions: listObjectVersionsStub,
deleteObjects: deleteObjectsStub,
},
},
});

expect(listObjectVersionsStub.calledOnce).to.be.equal(true);
expect(
listObjectVersionsStub.calledWithExactly({
Bucket: 'bucket',
Prefix: 'serverless/test-service/dev',
})
).to.be.equal(true);

expect(
deleteObjectsStub.calledWithExactly({
Bucket: 'bucket',
Delete: {
Objects: [
{ Key: 'object1', VersionId: null },
{ Key: 'object2', VersionId: 'v1' },
{ Key: 'object3', VersionId: 'v2' },
],
},
})
).to.be.equal(true);
});
});

describe('#deleteObjects()', () => {
it('should delete all objects in the S3 bucket', () => {
awsRemove.objectsInBucket = [{ Key: 'foo' }];
Expand Down