Repository: serverless/serverless (JavaScript)
Date: 2020-02-12 04:59:37+00:00
Task type: Feature

Issue:
ALB event Target Group names should have a prefix and not be completely random
# Feature Proposal

## Description

<!-- Please use https://forum.serverless.com, StackOverflow or other forums for Q&A -->
<!-- Please answer ALL the question below. Otherwise we probably have to close the issue due to missing information -->

Context:
I see the issue: https://github.com/serverless/serverless/issues/6320 and the original implementation is to use the `<lambda-function-name>-<stage>` and this resulted in non-unique names for target groups.

I also see the fix: https://github.com/serverless/serverless/pull/6322 to simply use a fixed length hash to generate the target group's name.

Problem:
The problem with this approach is that we no longer have (semi) predictable names. From a resource management point of view, an AWS account admin would require additional AWS API calls to retrieve target group tags before he/she can manage them properly. In a large AWS account, these additional API calls may amount to significant costs and even account-wide API call throttling.

The second problem with this approach is that we cannot give granular permissions to our user/codebuild continuous deployment to only create or delete target groups for restricted names.

eg. a target group arn created by serverless looks like: `arn:aws:elasticloadbalancing:us-east-1:<redacted>:targetgroup/9ff09e8a342a4186247803c28a7b67a3/f7eb2224b059657e`

This means that I MUST give permissions of `*`.

```
  statement {
    actions = [
      "elasticloadbalancing:CreateTargetGroup",
      "elasticloadbalancing:DeleteTargetGroup",
    ]

    resources = [
      "arn:aws:elasticloadbalancing:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:targetgroup/*",
    ]
  }
```

This will not sit well with security audits. They would prefer a more restrictive policy like

```
"arn:aws:elasticloadbalancing:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:targetgroup/${my_resource_prefix}*"
```

Proposal:
Introduce an `alb.targetPrefix` to be prepended to your fixed length hash. Total length should still be truncated to 32 characters and `alb.targetPrefix` should have a max length of... maybe 10 or 15 characters.


Similar or dependent issues:

- https://github.com/serverless/serverless/pull/6322
- https://github.com/serverless/serverless/issues/6320



Issue discussion:
@isen-ng thanks for report.

Indeed this could be improved.

Maybe good middle ground would be to do: `` `${serviceName}${md5hash}`.slice(0, 32) `` ?
The problem with using `${serviceName}${md5hash}.slice(0, 32)` is that, if the serviceName itself exceeds 32 characters, you will still end up with duplicate target group names.
@isen-ng Maybe then do:

```javascript
const prefix = userPrefix || serviceName.slice(0, 16);
targetGroupName = `${prefix}${md5hash}`.slice(0, 32)
```




@medikoo Your proposed solution works.

Just curious, why did you propose to want to use the (truncated) `serviceName` as a prefix as opposed to no prefix?

eg

```
md5hash = md5(serviceName);
targetGroupName = `${userPrefix}${md5hash}`.slice(0, 32);
```

if `userPrefix` is empty/null, then the targetGroupName will continue to be just the hash. This behaviour is backward compatible with the current implementation.
> Just curious, why did you propose to want to use the (truncated) serviceName as a prefix as opposed to no prefix?

As this for most cases will address issue you're reporting without any extra configuration tweaks (you may rely on service name as prefix, instead of setting your own).

> This behaviour is backward compatible with the current implementation.

 But indeed, my proposal could be breaking. If it is, we definitely should go with what you proposed.

Solution patch:
diff --git a/docs/providers/aws/events/alb.md b/docs/providers/aws/events/alb.md
index 54d4bace79e..03ae7b1332c 100644
--- a/docs/providers/aws/events/alb.md
+++ b/docs/providers/aws/events/alb.md
@@ -103,3 +103,25 @@ module.exports.hello = async (event, context, callback) => {
 ```
 
 The handler response object must use `multiValueHeaders` to set HTTP response headers, `headers` would be ignored.
+
+## Prepending a prefix to generated target group names
+
+By default, target group names are strings generated by hashing a combined string of the function name, alb's id and whether multi-value headers are enabled. This produces a fixed length, unique id for target groups.
+
+If you need target group names to have a common predictable prefix, you may preset the prefix with the `provider.alb.targetGroupPrefix` setting. Note maximum length of this prefix is 16 chars.
+
+```yml
+provider:
+  alb:
+    targetGroupPrefix: my-prefix
+
+functions:
+  albEventConsumer:
+    handler: handler.hello
+    events:
+      - alb:
+          listenerArn: arn:aws:elasticloadbalancing:us-east-1:12345:listener/app/my-load-balancer/50dc6c495c0c9188/
+          priority: 1
+          conditions:
+            path: /hello
+```
diff --git a/docs/providers/aws/guide/serverless.yml.md b/docs/providers/aws/guide/serverless.yml.md
index 99282a27333..c275ab0f9f7 100644
--- a/docs/providers/aws/guide/serverless.yml.md
+++ b/docs/providers/aws/guide/serverless.yml.md
@@ -75,6 +75,8 @@ provider:
     description: Some Description # Optional description for the API Gateway stage deployment
     binaryMediaTypes: # Optional binary media types the API might return
       - '*/*'
+  alb:
+    targetGroupPrefix: xxxxxxxxxx # Optional prefix to prepend when generating names for target groups
   usagePlan: # Optional usage plan configuration
     quota:
       limit: 5000
diff --git a/lib/plugins/aws/lib/naming.js b/lib/plugins/aws/lib/naming.js
index 906887ec86f..d585e4e947e 100644
--- a/lib/plugins/aws/lib/naming.js
+++ b/lib/plugins/aws/lib/naming.js
@@ -406,10 +406,13 @@ module.exports = {
     }${this.provider.getStage()}`;
   },
   getAlbTargetGroupName(functionName, albId, multiValueHeaders) {
-    return crypto
+    const hash = crypto
       .createHash('md5')
       .update(this.getAlbTargetGroupNameTagValue(functionName, albId, multiValueHeaders))
       .digest('hex');
+
+    const albTargetGroupName = this.provider.getAlbTargetGroupPrefix() + hash;
+    return albTargetGroupName.slice(0, 32);
   },
   getAlbListenerRuleLogicalId(functionName, idx) {
     return `${this.getNormalizedFunctionName(functionName)}AlbListenerRule${idx}`;
diff --git a/lib/plugins/aws/provider/awsProvider.js b/lib/plugins/aws/provider/awsProvider.js
index 5a968900505..f11c93ba518 100644
--- a/lib/plugins/aws/provider/awsProvider.js
+++ b/lib/plugins/aws/provider/awsProvider.js
@@ -498,6 +498,20 @@ class AwsProvider {
     return `${provider.deploymentPrefix}`;
   }
 
+  getAlbTargetGroupPrefix() {
+    const provider = this.serverless.service.provider;
+    if (!provider.alb || !provider.alb.targetGroupPrefix) {
+      return '';
+    }
+
+    if (provider.alb.targetGroupPrefix.length > 16) {
+      const errorMessage = `Length of alb.targetGroupPrefix should be at most 16 but is ${provider.alb.targetGroupPrefix.length}`;
+      throw new this.serverless.classes.Error(errorMessage);
+    }
+
+    return provider.alb.targetGroupPrefix;
+  }
+
   getLogRetentionInDays() {
     if (!_.has(this.serverless.service.provider, 'logRetentionInDays')) {
       return null;


Test patch:
diff --git a/lib/plugins/aws/lib/naming.test.js b/lib/plugins/aws/lib/naming.test.js
index 6ba60667f41..4bbf37a61ff 100644
--- a/lib/plugins/aws/lib/naming.test.js
+++ b/lib/plugins/aws/lib/naming.test.js
@@ -737,6 +737,15 @@ describe('#naming()', () => {
         '79039bd239ac0b3f6ff6d9296f23e27c'
       );
     });
+
+    it('should return a prefixed unique identifer of not longer than 32 characters if alb.targetGroupPrefix is set', () => {
+      serverless.service.service = 'myService';
+      serverless.service.provider.alb = {};
+      serverless.service.provider.alb.targetGroupPrefix = 'myPrefix-';
+      expect(sdk.naming.getAlbTargetGroupName('functionName', 'abc123', true)).to.equal(
+        'myPrefix-79039bd239ac0b3f6ff6d92'
+      );
+    });
   });
 
   describe('#getAlbTargetGroupNameTagValue()', () => {
diff --git a/lib/plugins/aws/provider/awsProvider.test.js b/lib/plugins/aws/provider/awsProvider.test.js
index 57a91e83a42..e656c9dc739 100644
--- a/lib/plugins/aws/provider/awsProvider.test.js
+++ b/lib/plugins/aws/provider/awsProvider.test.js
@@ -1347,6 +1347,44 @@ describe('AwsProvider', () => {
     });
   });
 
+  describe('#getAlbTargetGroupPrefix()', () => {
+    it('should return custom alb target group prefix if defined', () => {
+      serverless.service.provider.alb = {};
+      serverless.service.provider.alb.targetGroupPrefix = 'myPrefix';
+
+      expect(awsProvider.getAlbTargetGroupPrefix()).to.equal(
+        serverless.service.provider.alb.targetGroupPrefix
+      );
+    });
+
+    it('should return empty string if alb is not defined', () => {
+      serverless.service.provider.alb = undefined;
+
+      expect(awsProvider.getAlbTargetGroupPrefix()).to.equal('');
+    });
+
+    it('should return empty string if not defined', () => {
+      serverless.service.provider.alb = {};
+      serverless.service.provider.alb.targetGroupPrefix = undefined;
+
+      expect(awsProvider.getAlbTargetGroupPrefix()).to.equal('');
+    });
+
+    it('should throw error if prefix is longer than 16', () => {
+      serverless.service.provider.alb = {};
+      serverless.service.provider.alb.targetGroupPrefix = 'my-17-char-prefix';
+
+      expect(() => awsProvider.getAlbTargetGroupPrefix()).to.throw(Error);
+    });
+
+    it('should support no prefix', () => {
+      serverless.service.provider.alb = {};
+      serverless.service.provider.alb.targetGroupPrefix = '';
+
+      expect(awsProvider.getAlbTargetGroupPrefix()).to.equal('');
+    });
+  });
+
   describe('#getStage()', () => {
     it('should prefer options over config or provider', () => {
       const newOptions = {