Repository: serverless/serverless (JavaScript)
Date: 2018-09-16 08:09:12+00:00
Task type: Feature

Issue:
Allow specifying a custom prefix under which the artifacts will be deployed to S3
# This is a Feature Proposal

## Description

Currently, the prefix under which artifacts are deployed is hardcoded to `serverless`, which might not be a perfect fit for every setup. E.g. when using fine grained access control based on prefix where each team/service is assigned a role with access to a specific prefix in a shared S3 bucket.

Therefore, configuration should support an option for specifying a custom `deploymentPrefix` (or something similar) as in the example below.

```
provider:
  name: aws
  runtime: nodejs8.10
  stage: dev
  region: us-east-1
  deploymentPrefix: my-app-prefix
```

Willing to submit a PR if you would find it useful and we agree on what the config should look like.


Issue discussion:

Solution patch:
diff --git a/docs/providers/aws/guide/deploying.md b/docs/providers/aws/guide/deploying.md
index fa05211eb00..53b94e23cf0 100644
--- a/docs/providers/aws/guide/deploying.md
+++ b/docs/providers/aws/guide/deploying.md
@@ -68,6 +68,9 @@ The Serverless Framework translates all syntax in `serverless.yml` to a single A
 * You can specify your own S3 bucket which should be used to store all the deployment artifacts.
   The `deploymentBucket` config which is nested under `provider` lets you e.g. set the `name` or the `serverSideEncryption` method for this bucket
 
+* You can specify your own S3 prefix which should be used to store all the deployment artifacts.
+  The `deploymentPrefix` config which is nested under `provider` lets you set the prefix under which the deployment artifacts will be stored. If not specified, defaults to `serverless`.
+
 * You can make uploading to S3 faster by adding `--aws-s3-accelerate`
 
 Check out the [deploy command docs](../cli-reference/deploy.md) for all details and options.
diff --git a/docs/providers/aws/guide/serverless.yml.md b/docs/providers/aws/guide/serverless.yml.md
index c48c0f6d4f4..75b8b524895 100644
--- a/docs/providers/aws/guide/serverless.yml.md
+++ b/docs/providers/aws/guide/serverless.yml.md
@@ -37,6 +37,7 @@ provider:
   deploymentBucket:
     name: com.serverless.${self:provider.region}.deploys # Deployment bucket name. Default is generated by the framework
     serverSideEncryption: AES256 # when using server-side encryption
+  deploymentPrefix: serverless # The S3 prefix under which deployed artifacts should be stored. Default is serverless
   role: arn:aws:iam::XXXXXX:role/role # Overwrite the default IAM role which is used for all functions
   cfnRole: arn:aws:iam::XXXXXX:role/role # ARN of an IAM role for CloudFormation service. If specified, CloudFormation uses the role's credentials
   versionFunctions: false # Optional function versioning
diff --git a/docs/providers/aws/guide/services.md b/docs/providers/aws/guide/services.md
index f477df5a05b..0d20767c7f8 100644
--- a/docs/providers/aws/guide/services.md
+++ b/docs/providers/aws/guide/services.md
@@ -107,6 +107,7 @@ provider:
   deploymentBucket:
     name: com.serverless.${self:provider.region}.deploys # Overwrite the default deployment bucket
     serverSideEncryption: AES256 # when using server-side encryption
+  deploymentPrefix: serverless # Overwrite the default S3 prefix under which deployed artifacts should be stored. Default is serverless
   versionFunctions: false # Optional function versioning
   stackTags: # Optional CF stack tags
    key: value
diff --git a/lib/plugins/aws/deploy/lib/checkForChanges.js b/lib/plugins/aws/deploy/lib/checkForChanges.js
index 0254de25c00..ec8640875ae 100644
--- a/lib/plugins/aws/deploy/lib/checkForChanges.js
+++ b/lib/plugins/aws/deploy/lib/checkForChanges.js
@@ -27,7 +27,7 @@ module.exports = {
 
     const params = {
       Bucket: this.bucketName,
-      Prefix: `serverless/${service}/${this.provider.getStage()}`,
+      Prefix: `${this.provider.getDeploymentPrefix()}/${service}/${this.provider.getStage()}`,
     };
 
     return this.provider.request('S3',
diff --git a/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js b/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js
index e5440360946..4f2bbfb2de5 100644
--- a/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js
+++ b/lib/plugins/aws/deploy/lib/cleanupS3Bucket.js
@@ -10,18 +10,19 @@ module.exports = {
     const stacksToKeepCount = 5;
     const service = this.serverless.service.service;
     const stage = this.provider.getStage();
+    const prefix = this.provider.getDeploymentPrefix();
 
     return this.provider.request('S3',
       'listObjectsV2',
       {
         Bucket: this.bucketName,
-        Prefix: `serverless/${service}/${stage}`,
+        Prefix: `${prefix}/${service}/${stage}`,
       })
       .then((response) => {
-        const stacks = findAndGroupDeployments(response, service, stage);
+        const stacks = findAndGroupDeployments(response, prefix, service, stage);
         const stacksToKeep = _.takeRight(stacks, stacksToKeepCount);
         const stacksToRemove = _.pullAllWith(stacks, stacksToKeep, _.isEqual);
-        const objectsToRemove = getS3ObjectsFromStacks(stacksToRemove, service, stage);
+        const objectsToRemove = getS3ObjectsFromStacks(stacksToRemove, prefix, service, stage);
 
         if (objectsToRemove.length) {
           return BbPromise.resolve(objectsToRemove);
diff --git a/lib/plugins/aws/deployList/index.js b/lib/plugins/aws/deployList/index.js
index 00a70f98b86..81f6518545f 100644
--- a/lib/plugins/aws/deployList/index.js
+++ b/lib/plugins/aws/deployList/index.js
@@ -35,17 +35,18 @@ class AwsDeployList {
   listDeployments() {
     const service = this.serverless.service.service;
     const stage = this.provider.getStage();
+    const prefix = this.provider.getDeploymentPrefix();
 
     return this.provider.request('S3',
       'listObjectsV2',
       {
         Bucket: this.bucketName,
-        Prefix: `serverless/${service}/${stage}`,
+        Prefix: `${prefix}/${service}/${stage}`,
       }
     )
     .then((response) => {
       const directoryRegex = new RegExp('(.+)-(.+-.+-.+)');
-      const deployments = findAndGroupDeployments(response, service, stage);
+      const deployments = findAndGroupDeployments(response, prefix, service, stage);
 
       if (deployments.length === 0) {
         this.serverless.cli.log('Couldn\'t find any existing deployments.');
diff --git a/lib/plugins/aws/package/lib/generateArtifactDirectoryName.js b/lib/plugins/aws/package/lib/generateArtifactDirectoryName.js
index 8a384075472..795fd3081cb 100644
--- a/lib/plugins/aws/package/lib/generateArtifactDirectoryName.js
+++ b/lib/plugins/aws/package/lib/generateArtifactDirectoryName.js
@@ -7,8 +7,9 @@ module.exports = {
     const date = new Date();
     const serviceStage = `${this.serverless.service.service}/${this.provider.getStage()}`;
     const dateString = `${date.getTime().toString()}-${date.toISOString()}`;
+    const prefix = this.provider.getDeploymentPrefix();
     this.serverless.service.package
-      .artifactDirectoryName = `serverless/${serviceStage}/${dateString}`;
+      .artifactDirectoryName = `${prefix}/${serviceStage}/${dateString}`;
 
     return BbPromise.resolve();
   },
diff --git a/lib/plugins/aws/provider/awsProvider.js b/lib/plugins/aws/provider/awsProvider.js
index 440735642bd..53b49ee018e 100644
--- a/lib/plugins/aws/provider/awsProvider.js
+++ b/lib/plugins/aws/provider/awsProvider.js
@@ -266,6 +266,7 @@ class AwsProvider {
             { providerError: _.assign({}, err, { retryable: false }) }
           ));
         }
+
         return BbPromise.reject(Object.assign(
           new this.serverless.classes.Error(message, err.statusCode),
           { providerError: err }
@@ -390,6 +391,10 @@ class AwsProvider {
     ).then((result) => result.StackResourceDetail.PhysicalResourceId);
   }
 
+  getDeploymentPrefix() {
+    return this.serverless.service.provider.deploymentPrefix || 'serverless';
+  }
+
   getStageSourceValue() {
     const values = this.getValues(this, [
       ['options', 'stage'],
diff --git a/lib/plugins/aws/remove/lib/bucket.js b/lib/plugins/aws/remove/lib/bucket.js
index d9308d2c146..b67abf82eee 100644
--- a/lib/plugins/aws/remove/lib/bucket.js
+++ b/lib/plugins/aws/remove/lib/bucket.js
@@ -18,7 +18,7 @@ module.exports = {
 
     return this.provider.request('S3', 'listObjectsV2', {
       Bucket: this.bucketName,
-      Prefix: `serverless/${serviceStage}`,
+      Prefix: `${this.provider.getDeploymentPrefix()}/${serviceStage}`,
     }).then((result) => {
       if (result) {
         result.Contents.forEach((object) => {
diff --git a/lib/plugins/aws/rollback/index.js b/lib/plugins/aws/rollback/index.js
index 9a7ca5edc25..296da4df130 100644
--- a/lib/plugins/aws/rollback/index.js
+++ b/lib/plugins/aws/rollback/index.js
@@ -47,7 +47,8 @@ class AwsRollback {
     const service = this.serverless.service;
     const serviceName = this.serverless.service.service;
     const stage = this.provider.getStage();
-    const prefix = `serverless/${serviceName}/${stage}`;
+    const deploymentPrefix = this.provider.getDeploymentPrefix();
+    const prefix = `${deploymentPrefix}/${serviceName}/${stage}`;
 
     return this.provider.request('S3',
       'listObjectsV2',
@@ -56,7 +57,7 @@ class AwsRollback {
         Prefix: prefix,
       })
       .then((response) => {
-        const deployments = findAndGroupDeployments(response, serviceName, stage);
+        const deployments = findAndGroupDeployments(response, deploymentPrefix, serviceName, stage);
 
         if (deployments.length === 0) {
           const msg = 'Couldn\'t find any existing deployments.';
diff --git a/lib/plugins/aws/utils/findAndGroupDeployments.js b/lib/plugins/aws/utils/findAndGroupDeployments.js
index 0dc9a570a2a..aef6e7d369d 100644
--- a/lib/plugins/aws/utils/findAndGroupDeployments.js
+++ b/lib/plugins/aws/utils/findAndGroupDeployments.js
@@ -2,9 +2,9 @@
 
 const _ = require('lodash');
 
-module.exports = (s3Response, service, stage) => {
+module.exports = (s3Response, prefix, service, stage) => {
   if (s3Response.Contents.length) {
-    const regex = new RegExp(`serverless/${service}/${stage}/(.+-.+-.+-.+)/(.+)`);
+    const regex = new RegExp(`${prefix}/${service}/${stage}/(.+-.+-.+-.+)/(.+)`);
     const s3Objects = s3Response.Contents.filter((s3Object) => s3Object.Key.match(regex));
     const names = s3Objects.map((s3Object) => {
       const match = s3Object.Key.match(regex);
diff --git a/lib/plugins/aws/utils/getS3ObjectsFromStacks.js b/lib/plugins/aws/utils/getS3ObjectsFromStacks.js
index 8ae79200aae..1a98dc82217 100644
--- a/lib/plugins/aws/utils/getS3ObjectsFromStacks.js
+++ b/lib/plugins/aws/utils/getS3ObjectsFromStacks.js
@@ -2,8 +2,8 @@
 
 const _ = require('lodash');
 
-module.exports = (stacks, service, stage) => (
+module.exports = (stacks, prefix, service, stage) => (
   _.flatten(stacks).map((entry) => (
-    { Key: `serverless/${service}/${stage}/${entry.directory}/${entry.file}` })
+    { Key: `${prefix}/${service}/${stage}/${entry.directory}/${entry.file}` })
   )
 );


Test patch:
diff --git a/lib/plugins/aws/deploy/lib/cleanupS3Bucket.test.js b/lib/plugins/aws/deploy/lib/cleanupS3Bucket.test.js
index d15d6cd7ab4..84447eedce2 100644
--- a/lib/plugins/aws/deploy/lib/cleanupS3Bucket.test.js
+++ b/lib/plugins/aws/deploy/lib/cleanupS3Bucket.test.js
@@ -29,7 +29,8 @@ describe('cleanupS3Bucket', () => {
     provider = new AwsProvider(serverless, options);
     serverless.setProvider('aws', provider);
     serverless.service.service = 'cleanupS3Bucket';
-    s3Key = `serverless/${serverless.service.service}/${provider.getStage()}`;
+    const prefix = provider.getDeploymentPrefix();
+    s3Key = `${prefix}/${serverless.service.service}/${provider.getStage()}`;
     awsDeploy = new AwsDeploy(serverless, options);
     awsDeploy.bucketName = 'deployment-bucket';
     awsDeploy.serverless.cli = new serverless.classes.CLI();
diff --git a/lib/plugins/aws/deployList/index.test.js b/lib/plugins/aws/deployList/index.test.js
index e6aea30bce1..92ea6568b44 100644
--- a/lib/plugins/aws/deployList/index.test.js
+++ b/lib/plugins/aws/deployList/index.test.js
@@ -21,7 +21,8 @@ describe('AwsDeployList', () => {
     provider = new AwsProvider(serverless, options);
     serverless.setProvider('aws', provider);
     serverless.service.service = 'listDeployments';
-    s3Key = `serverless/${serverless.service.service}/${provider.getStage()}`;
+    const prefix = provider.getDeploymentPrefix();
+    s3Key = `${prefix}/${serverless.service.service}/${provider.getStage()}`;
     awsDeployList = new AwsDeployList(serverless, options);
     awsDeployList.bucketName = 'deployment-bucket';
     awsDeployList.serverless.cli = {
diff --git a/lib/plugins/aws/provider/awsProvider.test.js b/lib/plugins/aws/provider/awsProvider.test.js
index b518cc28dc7..cc31e3be66c 100644
--- a/lib/plugins/aws/provider/awsProvider.test.js
+++ b/lib/plugins/aws/provider/awsProvider.test.js
@@ -911,6 +911,21 @@ describe('AwsProvider', () => {
     });
   });
 
+  describe('#getDeploymentPrefix()', () => {
+    it('should return custom deployment prefix if defined', () => {
+      serverless.service.provider.deploymentPrefix = 'providerPrefix';
+
+      expect(awsProvider.getDeploymentPrefix())
+        .to.equal(serverless.service.provider.deploymentPrefix);
+    });
+
+    it('should use the default serverless if not defined', () => {
+      serverless.service.provider.deploymentPrefix = undefined;
+
+      expect(awsProvider.getDeploymentPrefix()).to.equal('serverless');
+    });
+  });
+
   describe('#getStage()', () => {
     it('should prefer options over config or provider', () => {
       const newOptions = {
diff --git a/lib/plugins/aws/remove/lib/bucket.test.js b/lib/plugins/aws/remove/lib/bucket.test.js
index e4b8b77ebfb..8714e96fc10 100644
--- a/lib/plugins/aws/remove/lib/bucket.test.js
+++ b/lib/plugins/aws/remove/lib/bucket.test.js
@@ -42,6 +42,9 @@ describe('emptyS3Bucket', () => {
       const listObjectsStub = sinon.stub(awsRemove.provider, 'request')
         .resolves();
 
+      const stage = awsRemove.provider.getStage();
+      const prefix = awsRemove.provider.getDeploymentPrefix();
+
       return awsRemove.listObjects().then(() => {
         expect(listObjectsStub.calledOnce).to.be.equal(true);
         expect(listObjectsStub.calledWithExactly(
@@ -49,7 +52,7 @@ describe('emptyS3Bucket', () => {
           'listObjectsV2',
           {
             Bucket: awsRemove.bucketName,
-            Prefix: `serverless/${serverless.service.service}/${awsRemove.provider.getStage()}`,
+            Prefix: `${prefix}/${serverless.service.service}/${stage}`,
           }
         )).to.be.equal(true);
         expect(awsRemove.objectsInBucket.length).to.equal(0);
@@ -66,6 +69,9 @@ describe('emptyS3Bucket', () => {
           ],
         });
 
+      const stage = awsRemove.provider.getStage();
+      const prefix = awsRemove.provider.getDeploymentPrefix();
+
       return awsRemove.listObjects().then(() => {
         expect(listObjectsStub.calledOnce).to.be.equal(true);
         expect(listObjectsStub.calledWithExactly(
@@ -73,7 +79,7 @@ describe('emptyS3Bucket', () => {
           'listObjectsV2',
           {
             Bucket: awsRemove.bucketName,
-            Prefix: `serverless/${serverless.service.service}/${awsRemove.provider.getStage()}`,
+            Prefix: `${prefix}/${serverless.service.service}/${stage}`,
           }
         )).to.be.equal(true);
         expect(awsRemove.objectsInBucket[0]).to.deep.equal({ Key: 'object1' });
diff --git a/lib/plugins/aws/rollback/index.test.js b/lib/plugins/aws/rollback/index.test.js
index edd096c5f4d..93549df5cbe 100644
--- a/lib/plugins/aws/rollback/index.test.js
+++ b/lib/plugins/aws/rollback/index.test.js
@@ -27,7 +27,8 @@ describe('AwsRollback', () => {
     spawnStub = sinon.stub(serverless.pluginManager, 'spawn');
     awsRollback = new AwsRollback(serverless, options);
     awsRollback.serverless.cli = new serverless.classes.CLI();
-    s3Key = `serverless/${serverless.service.service}/${provider.getStage()}`;
+    const prefix = provider.getDeploymentPrefix();
+    s3Key = `${prefix}/${serverless.service.service}/${provider.getStage()}`;
   });
 
   afterEach(() => {
diff --git a/lib/plugins/aws/utils/findAndGroupDeployments.test.js b/lib/plugins/aws/utils/findAndGroupDeployments.test.js
index 0977869b12e..17406cf8468 100644
--- a/lib/plugins/aws/utils/findAndGroupDeployments.test.js
+++ b/lib/plugins/aws/utils/findAndGroupDeployments.test.js
@@ -9,7 +9,7 @@ describe('#findAndGroupDeployments()', () => {
       Contents: [],
     };
 
-    expect(findAndGroupDeployments(s3Response, 'test', 'dev')).to.deep.equal([]);
+    expect(findAndGroupDeployments(s3Response, 'serverless', 'test', 'dev')).to.deep.equal([]);
   });
 
   it('should group stacks', () => {
@@ -73,6 +73,7 @@ describe('#findAndGroupDeployments()', () => {
       ],
     ];
 
-    expect(findAndGroupDeployments(s3Response, 'test', 'dev')).to.deep.equal(expected);
+    expect(findAndGroupDeployments(s3Response, 'serverless', 'test', 'dev'))
+      .to.deep.equal(expected);
   });
 });
diff --git a/lib/plugins/aws/utils/getS3ObjectsFromStacks.test.js b/lib/plugins/aws/utils/getS3ObjectsFromStacks.test.js
index d5f9851a786..df6f4bedaa4 100644
--- a/lib/plugins/aws/utils/getS3ObjectsFromStacks.test.js
+++ b/lib/plugins/aws/utils/getS3ObjectsFromStacks.test.js
@@ -5,7 +5,7 @@ const getS3ObjectsFromStacks = require('./getS3ObjectsFromStacks');
 
 describe('#getS3ObjectsFromStacks()', () => {
   it('should return an empty result in case no stacks are provided', () => {
-    expect(getS3ObjectsFromStacks([], 'test', 'dev')).to.deep.equal([]);
+    expect(getS3ObjectsFromStacks([], 'serverless', 'test', 'dev')).to.deep.equal([]);
   });
 
   it('should return an empty result in case no stacks are provided', () => {
@@ -41,6 +41,6 @@ describe('#getS3ObjectsFromStacks()', () => {
       { Key: 'serverless/test/dev/1476779278222-2016-10-18T08:27:58.222Z/test.zip' },
     ];
 
-    expect(getS3ObjectsFromStacks(stacks, 'test', 'dev')).to.deep.equal(expected);
+    expect(getS3ObjectsFromStacks(stacks, 'serverless', 'test', 'dev')).to.deep.equal(expected);
   });
 });