Repository: Miserlou/Zappa

Issue:
Zappa shouldn't sluggify non-default project names
Configuration option `project_name` defaults to sluggified pwd (which I hope means the current dir, not the entire path, but thats beside the point), but it shouldn't sluggify custom project names.

Alternatively, it should be at least noted in documentation.


Discussion:
What are you trying to use?

It's just trying to force a valid name but there are probably better ways of doing that than what's available now.
PascalCase.

`ZappaTestArch` in `dev` config is deployed as `zappatestarch-dev` and I would like it to be deployed as `ZappaTestArch-dev`
Ahhh. You're probably right, it should just let the user shoot themselves in the foot if that's what they want.
Since there is a PR open that would fix this, I'd like to bump the issue.
+1. It would make our lambda functions in AWS console a bit better readable, currently zappa managed ones break our naming conventions.

I checked the pull request code and it seems fine to me.

The new version would in fact allow to shoot in direction, the user aimed really (now it hits sometime a bit lower :-)).

If we care about preventing dangerous names, we could extend the test in place, which actually tests only for the length exceeding 16 chars, it could get extra check for other cases. 

AWS Lambda [spec states](http://docs.aws.amazon.com/lambda/latest/dg/API_CreateFunction.html#API_CreateFunction_RequestSyntax)

Type: String

Length Constraints: Minimum length of 1. Maximum length of 140.

Pattern: ```(arn:aws:lambda:)?([a-z]{2}-[a-z]+-\d{1}:)?(\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\$LATEST|[a-zA-Z0-9-_]+))?```

I could make PR for that if it seems reasonable solution.
+1 to matching the real validation, PR welcome.
My proposal for validating lambda function name:

The pattern breaks down to following parts::

    # optional prefix 'arn:aws:lambda:'
    (arn:aws:lambda:)?
    # optional region name, e.g. 'eu-west-1'
    ([a-z]{2}-[a-z]+-\d{1}:)?
    # optional account id, e.g.: '123456789012'
    (\d{12}:)?
    # optional 'function:' string
    (function:)?
    # obligatory: actual name of the Lambda function
    ([a-zA-Z0-9-_]+)
    # optional suffix (alias, version or event source mapping-id)
    (:(\$LATEST|[a-zA-Z0-9-_]+))?


Real (fully qualified) lambda name may look as follows::

    arn:aws:lambda:ap-southeast-2:123456789012:function:actual-function-name:$LATEST

Lambda is supported in following regions:

- us-east-1
- us-east-2
- us-west-1
- us-west-2
- ap-northeast-2
- ap-southeast-1
- ap-southeast-2
- ap-northeast-1
- eu-central-1
- eu-west-1

The longest one is used in above example.

Calculating max lenght of actual-function-name:

- prefix:52 (`arn:....:function:`)
- suffix/alias:8 (`:$LATEST`, integer version or version alias - assuming version aliases used will not be longer than 7 chars)

length remaining from total 140 chars: 140 - (52+8) = 80

Pattern for actual-function-name: `[a-zA-Z0-9-_]+`[a-zA-Z0-9-_]+

The test would do three tests:
- string is not empty `len(name) > 0`
- string match the pattern `[a-zA-Z0-9-_]+`
- string is not longer than 80 chars `len(name) <= 80`

If the test fails, I would stop processing, because trying to improve broken name may surprise the user.

Resolving patch:
diff --git a/zappa/cli.py b/zappa/cli.py
--- a/zappa/cli.py
+++ b/zappa/cli.py
@@ -42,7 +42,8 @@
 from datetime import datetime,timedelta
 from zappa import Zappa, logger, API_GATEWAY_REGIONS
 from util import (check_new_version_available, detect_django_settings,
-                  detect_flask_apps, parse_s3_url, human_size)
+                  detect_flask_apps, parse_s3_url, human_size,
+                  validate_name, InvalidAwsLambdaName)
 
 
 CUSTOM_SETTINGS = [
@@ -1518,17 +1519,14 @@ def load_settings(self, settings_file=None, session=None):
 
         # We need a working title for this project. Use one if supplied, else cwd dirname.
         if 'project_name' in self.stage_config: # pragma: no cover
-            self.project_name = self.stage_config['project_name']
+            # If the name is invalid, this will throw an exception with message up stack
+            self.project_name = validate_name(self.stage_config['project_name'])
         else:
             self.project_name = slugify.slugify(os.getcwd().split(os.sep)[-1])[:15]
 
-        if len(self.project_name) > 15: # pragma: no cover
-            click.echo(click.style("Warning", fg="red", bold=True) + "! Your " + click.style("project_name", bold=True) +
-                       " may be too long to deploy! Please make it <16 characters.")
-
         # The name of the actual AWS Lambda function, ex, 'helloworld-dev'
-        # Django's slugify doesn't replace _, but this does.
-        self.lambda_name = slugify.slugify(self.project_name + '-' + self.api_stage)
+        # Assume that we already have have validated the name beforehand.
+        self.lambda_name = self.project_name + '-' + self.api_stage
 
         # Load environment-specific settings
         self.s3_bucket_name = self.stage_config.get('s3_bucket', "zappa-" + ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(9)))
diff --git a/zappa/util.py b/zappa/util.py
--- a/zappa/util.py
+++ b/zappa/util.py
@@ -4,6 +4,7 @@
 import fnmatch
 import json
 import os
+import re
 import requests
 import shutil
 import stat
@@ -291,3 +292,38 @@ def check_new_version_available(this_version):
         return True
     else:
         return False
+
+
+class InvalidAwsLambdaName(Exception):
+    """Exception: proposed AWS Lambda name is invalid"""
+    pass
+
+
+def validate_name(name, maxlen=80):
+    """Validate name for AWS Lambda function.
+    name: actual name (without `arn:aws:lambda:...:` prefix and without
+        `:$LATEST`, alias or version suffix.
+    maxlen: max allowed length for name without prefix and suffix.
+
+    The value 80 was calculated from prefix with longest known region name
+    and assuming that no alias or version would be longer than `$LATEST`.
+
+    Based on AWS Lambda spec
+    http://docs.aws.amazon.com/lambda/latest/dg/API_CreateFunction.html
+
+    Return: the name
+    Raise: InvalidAwsLambdaName, if the name is invalid.
+    """
+    if not isinstance(name, basestring):
+        msg = "Name must be of type string"
+        raise InvalidAwsLambdaName(msg)
+    if len(name) > maxlen:
+        msg = "Name is longer than {maxlen} characters."
+        raise InvalidAwsLambdaName(msg.format(maxlen=maxlen))
+    if len(name) == 0:
+        msg = "Name must not be empty string."
+        raise InvalidAwsLambdaName(msg)
+    if not re.match("^[a-zA-Z0-9-_]+$", name):
+        msg = "Name can only contain characters from a-z, A-Z, 0-9, _ and -"
+        raise InvalidAwsLambdaName(msg)
+    return name


Test patch:
diff --git a/tests/name_scenarios.json b/tests/name_scenarios.json
new file mode 100644
--- /dev/null
+++ b/tests/name_scenarios.json
@@ -0,0 +1,66 @@
+[
+  {
+    "id": "integer",
+    "value": 12,
+    "is_valid": false
+  },
+  {
+    "id": "float",
+    "value": 3.1415,
+    "is_valid": false
+  },
+  {
+    "id": "list",
+    "value": [
+      "a",
+      1,
+      null
+    ],
+    "is_valid": false
+  },
+  {
+    "id": null,
+    "value": null,
+    "is_valid": false
+  },
+  {
+    "id": "empty string",
+    "value": "",
+    "is_valid": false
+  },
+  {
+    "id": "ok",
+    "value": "FrancoZappa",
+    "is_valid": true
+  },
+  {
+    "id": "with space",
+    "value": "Franco Zappa",
+    "is_valid": false
+  },
+  {
+    "id": "one char",
+    "value": "A",
+    "is_valid": true
+  },
+  {
+    "id": "one digit",
+    "value": "5",
+    "is_valid": true
+  },
+  {
+    "id": "80 chars",
+    "value": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
+    "is_valid": true
+  },
+  {
+    "id": "81 chars",
+    "value": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzza",
+    "is_valid": false
+  },
+  {
+    "id": "bad_chars",
+    "value": "hi&bye",
+    "is_valid": false
+  }
+]
diff --git a/tests/tests.py b/tests/tests.py
--- a/tests/tests.py
+++ b/tests/tests.py
@@ -29,7 +29,8 @@
 from zappa.letsencrypt import get_cert_and_update_domain, create_domain_key, create_domain_csr, create_chained_certificate, get_cert, cleanup, parse_account_key, parse_csr, sign_certificate, encode_certificate, register_account, verify_challenge
 from zappa.util import (detect_django_settings, copytree, detect_flask_apps,
                         add_event_source, remove_event_source,
-                        get_event_source_status, parse_s3_url, human_size, string_to_timestamp)
+                        get_event_source_status, parse_s3_url, human_size, string_to_timestamp,
+                        validate_name, InvalidAwsLambdaName)
 from zappa.wsgi import create_wsgi_request, common_log
 from zappa.zappa import Zappa, ASSUME_POLICY, ATTACH_POLICY
 
@@ -1391,5 +1392,22 @@ def test_slim_handler(self):
 
         zappa_cli.remove_local_zip()
 
+
+
+    def test_validate_name(self):
+        fname = 'tests/name_scenarios.json'
+        with open(fname, 'r') as f:
+            scenarios = json.load(f)
+        for scenario in scenarios:
+            value = scenario["value"]
+            is_valid = scenario["is_valid"]
+            if is_valid:
+                assert validate_name(value)
+            else:
+                with self.assertRaises(InvalidAwsLambdaName) as exc:
+                    validate_name(value)
+
+
+
 if __name__ == '__main__':
     unittest.main()