Skip to content
Merged
95 changes: 84 additions & 11 deletions bin/sam-translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@
Known limitations: cannot transform CodeUri pointing at local directory.

Usage:
sam-translate.py --input-file=sam-template.yaml [--output-file=<o>]
sam-translate.py --template-file=sam-template.yaml [--verbose] [--output-template=<o>]
sam-translate.py package --template-file=sam-template.yaml --s3-bucket=my-bucket [--verbose] [--output-template=<o>]
sam-translate.py deploy --template-file=sam-template.yaml --s3-bucket=my-bucket --capabilities=CAPABILITY_NAMED_IAM --stack-name=my-stack [--verbose] [--output-template=<o>]

Options:
--input-file=<i> Location of SAM template to transform.
--output-file=<o> Location to store resulting CloudFormation template [default: cfn-template.json].
--template-file=<i> Location of SAM template to transform [default: template.yaml].
--output-template=<o> Location to store resulting CloudFormation template [default: transformed-template.json].
--s3-bucket=<s> S3 bucket to use for SAM artifacts when using the `package` command
--capabilities=<c> Capabilities
--stack-name=<n> Unique name for your CloudFormation Stack
--verbose Enables verbose logging

"""
import json
import logging
import os
import platform
import subprocess
import sys

import boto3
from docopt import docopt
Expand All @@ -23,24 +33,60 @@
from samtranslator.yaml_helper import yaml_parse
from samtranslator.model.exceptions import InvalidDocumentException


LOG = logging.getLogger(__name__)
cli_options = docopt(__doc__)
iam_client = boto3.client('iam')
cwd = os.getcwd()

if cli_options.get('--verbose'):
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig()

def execute_command(command, args):
try:
aws_cmd = 'aws' if platform.system().lower() != 'windows' else 'aws.cmd'
command_with_args = [aws_cmd, 'cloudformation', command] + list(args)

LOG.debug("Executing command: %s", command_with_args)

subprocess.check_call(command_with_args)

LOG.debug("Command successful")
except subprocess.CalledProcessError as e:
# Underlying aws command will print the exception to the user
LOG.debug("Exception: %s", e)
sys.exit(e.returncode)


def get_input_output_file_paths():
input_file_option = cli_options.get('--input-file')
output_file_option = cli_options.get('--output-file')
input_file_option = cli_options.get('--template-file')
output_file_option = cli_options.get('--output-template')
input_file_path = os.path.join(cwd, input_file_option)
output_file_path = os.path.join(cwd, output_file_option)

return input_file_path, output_file_path


def main():
input_file_path, output_file_path = get_input_output_file_paths()
def package(input_file_path, output_file_path):
template_file = input_file_path
package_output_template_file = input_file_path + '._sam_packaged_.yaml'
s3_bucket = cli_options.get('--s3-bucket')
args = [
'--template-file',
template_file,
'--output-template-file',
package_output_template_file,
'--s3-bucket',
s3_bucket
]

execute_command('package', args)

return package_output_template_file


def transform_template(input_file_path, output_file_path):
with open(input_file_path, 'r') as f:
sam_template = yaml_parse(f)

Expand All @@ -56,10 +102,37 @@ def main():
print('Wrote transformed CloudFormation template to: ' + output_file_path)
except InvalidDocumentException as e:
errorMessage = reduce(lambda message, error: message + ' ' + error.message, e.causes, e.message)
print(errorMessage)
LOG.error(errorMessage)
errors = map(lambda cause: cause.message, e.causes)
print(errors)
LOG.error(errors)


def deploy(template_file):
capabilities = cli_options.get('--capabilities')
stack_name = cli_options.get('--stack-name')
args = [
'--template-file',
template_file,
'--capabilities',
capabilities,
'--stack-name',
stack_name
]

execute_command('deploy', args)

return package_output_template_file


if __name__ == '__main__':
main()
input_file_path, output_file_path = get_input_output_file_paths()

if cli_options.get('package'):
package_output_template_file = package(input_file_path, output_file_path)
transform_template(package_output_template_file, output_file_path)
elif cli_options.get('deploy'):
package_output_template_file = package(input_file_path, output_file_path)
transform_template(package_output_template_file, output_file_path)
deploy(output_file_path)
else:
transform_template(input_file_path, output_file_path)
12 changes: 9 additions & 3 deletions samtranslator/intrinsics/actions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re

from six import string_types
from samtranslator.model.exceptions import InvalidTemplateException
from samtranslator.model.exceptions import InvalidTemplateException, InvalidDocumentException


class Action(object):
Expand Down Expand Up @@ -427,6 +427,11 @@ def resolve_resource_refs(self, input_dict, supported_resource_refs):
if not isinstance(value, list) or len(value) < 2:
return input_dict

if (not all(isinstance(entry, string_types) for entry in value)):
raise InvalidDocumentException(
[InvalidTemplateException('Invalid GetAtt value {}. GetAtt expects an array with 2 strings.'
.format(value))])

# Value of GetAtt is an array. It can contain any number of elements, with first being the LogicalId of
# resource and rest being the attributes. In a SAM template, a reference to a resource can be used in the
# first parameter. However tools like AWS CLI might break them down as well. So let's just concatenate
Expand Down Expand Up @@ -529,8 +534,9 @@ def resolve_parameter_refs(self, input_dict, parameters):

# FindInMap expects an array with 3 values
if not isinstance(value, list) or len(value) != 3:
raise InvalidTemplateException('Invalid FindInMap value {}. FindInMap expects an array with 3 values.'
.format(value))
raise InvalidDocumentException(
[InvalidTemplateException('Invalid FindInMap value {}. FindInMap expects an array with 3 values.'
.format(value))])

map_name = self.resolve_parameter_refs(value[0], parameters)
top_level_key = self.resolve_parameter_refs(value[1], parameters)
Expand Down
4 changes: 3 additions & 1 deletion samtranslator/model/eventsources/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from samtranslator.model.events import EventsRule
from samtranslator.model.iot import IotTopicRule
from samtranslator.translator.arn_generator import ArnGenerator
from samtranslator.model.exceptions import InvalidEventException
from samtranslator.model.exceptions import InvalidEventException, InvalidResourceException
from samtranslator.swagger.swagger import SwaggerEditor

CONDITION = 'Condition'
Expand Down Expand Up @@ -419,6 +419,8 @@ def resources_to_link(self, resources):

# Stage could be a intrinsic, in which case leave the suffix to default value
if isinstance(permitted_stage, string_types):
if not permitted_stage:
raise InvalidResourceException(rest_api_id, 'StageName cannot be empty.')
stage_suffix = permitted_stage
else:
stage_suffix = "Stage"
Expand Down
2 changes: 1 addition & 1 deletion samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ def _construct_alias(self, name, function, version):
"""

if not name:
raise ValueError("Alias name is required to create an alias")
raise InvalidResourceException(self.logical_id, "Alias name is required to create an alias")

logical_id = "{id}Alias{suffix}".format(id=function.logical_id, suffix=name)
alias = LambdaAlias(logical_id=logical_id, attributes=self.get_passthrough_resource_attributes())
Expand Down
18 changes: 16 additions & 2 deletions samtranslator/parser/parser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from samtranslator.model.exceptions import InvalidDocumentException, InvalidTemplateException
from samtranslator.model.exceptions import InvalidDocumentException, InvalidTemplateException, InvalidResourceException
from samtranslator.validator.validator import SamTemplateValidator
from samtranslator.plugins import LifeCycleEvents
from samtranslator.public.sdk.template import SamTemplate


class Parser:
Expand All @@ -26,11 +27,24 @@ def _validate(self, sam_template, parameter_values):
raise InvalidDocumentException(
[InvalidTemplateException("'Resources' section is required")])

if (not all(isinstance(value, dict) for value in sam_template["Resources"].values())):
if (not all(isinstance(sam_resource, dict) for sam_resource in sam_template["Resources"].values())):
raise InvalidDocumentException(
[InvalidTemplateException(
"All 'Resources' must be Objects. If you're using YAML, this may be an "
"indentation issue."
)])

sam_template_instance = SamTemplate(sam_template)

for resource_logical_id, sam_resource in sam_template_instance.iterate():
# NOTE: Properties isn't required for SimpleTable, so we can't check
# `not isinstance(sam_resources.get("Properties"), dict)` as this would be a breaking change.
# sam_resource.properties defaults to {} in SamTemplate init
if (not isinstance(sam_resource.properties, dict)):
raise InvalidDocumentException(
[InvalidResourceException(resource_logical_id,
"All 'Resources' must be Objects and have a 'Properties' Object. If "
"you're using YAML, this may be an indentation issue."
)])

SamTemplateValidator.validate(sam_template)
22 changes: 21 additions & 1 deletion samtranslator/plugins/application/serverless_app_plugin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import boto3
import json
from botocore.exceptions import ClientError, EndpointConnectionError
import logging
from time import sleep, time
Expand Down Expand Up @@ -86,10 +87,17 @@ def on_before_transform_template(self, template_dict):

app_id = self._replace_value(app.properties[self.LOCATION_KEY],
self.APPLICATION_ID_KEY, intrinsic_resolvers)

semver = self._replace_value(app.properties[self.LOCATION_KEY],
self.SEMANTIC_VERSION_KEY, intrinsic_resolvers)

if isinstance(app_id, dict) or isinstance(semver, dict):
key = (json.dumps(app_id), json.dumps(semver))
self._applications[key] = False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you choose to set this to False?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just needed a value here. Wanted to get any other recommendations from you.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. We're getting a value here because we don't resolve intrinsics until later, right? Could we resolve them earlier so that the flow in this area remains the same?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can actually stick an error instead of False into self._applications[key]. If the app_id or semver is still a dict, we won't resolve it any further so we'll just have to return an error at some point.

continue

key = (app_id, semver)

if key not in self._applications:
try:
# Lazy initialization of the client- create it when it is needed
Expand Down Expand Up @@ -211,11 +219,23 @@ def on_before_transform_resource(self, logical_id, resource_type, resource_prope
[self.APPLICATION_ID_KEY, self.SEMANTIC_VERSION_KEY])

app_id = resource_properties[self.LOCATION_KEY].get(self.APPLICATION_ID_KEY)

if not app_id:
raise InvalidResourceException(logical_id, "Property 'ApplicationId' cannot be blank.")

if isinstance(app_id, dict):
raise InvalidResourceException(logical_id, "Property 'ApplicationId' cannot be resolved. Only FindInMap "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error messages for both of these instances are practically the same, any way we could re-use the verbage and insert the property name?

"and Ref intrinsic functions are supported.")

semver = resource_properties[self.LOCATION_KEY].get(self.SEMANTIC_VERSION_KEY)

if not semver:
raise InvalidResourceException(logical_id, "Property 'SemanticVersion cannot be blank.")
raise InvalidResourceException(logical_id, "Property 'SemanticVersion' cannot be blank.")

if isinstance(semver, dict):
raise InvalidResourceException(logical_id, "Property 'SemanticVersion' cannot be resolved. Only FindInMap "
"and Ref intrinsic functions are supported.")

key = (app_id, semver)

# Throw any resource exceptions saved from the before_transform_template event
Expand Down
5 changes: 4 additions & 1 deletion samtranslator/swagger/swagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from samtranslator.model.intrinsics import ref
from samtranslator.model.intrinsics import make_conditional
from samtranslator.model.exceptions import InvalidDocumentException, InvalidTemplateException


class SwaggerEditor(object):
Expand Down Expand Up @@ -124,7 +125,9 @@ def add_path(self, path, method=None):

if not isinstance(path_dict, dict):
# Either customers has provided us an invalid Swagger, or this class has messed it somehow
raise ValueError("Value of '{}' path must be a dictionary according to Swagger spec".format(path))
raise InvalidDocumentException(
[InvalidTemplateException("Value of '{}' path must be a dictionary according to Swagger spec."
.format(path))])

if self._CONDITIONAL_IF in path_dict:
path_dict = path_dict[self._CONDITIONAL_IF][1]
Expand Down
6 changes: 3 additions & 3 deletions tests/intrinsics/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from mock import patch, Mock
from samtranslator.intrinsics.actions import Action, RefAction, SubAction, GetAttAction, FindInMapAction
from samtranslator.intrinsics.resource_refs import SupportedResourceReferences
from samtranslator.model.exceptions import InvalidTemplateException
from samtranslator.model.exceptions import InvalidTemplateException, InvalidDocumentException

class TestAction(TestCase):

Expand Down Expand Up @@ -954,15 +954,15 @@ def test_value_not_list(self):
input = {
"Fn::FindInMap": "a string"
}
with self.assertRaises(InvalidTemplateException):
with self.assertRaises(InvalidDocumentException):
self.ref.resolve_parameter_refs(input, {})

def test_value_not_list_of_length_three(self):
input = {
"Fn::FindInMap": ["a string"]
}

with self.assertRaises(InvalidTemplateException):
with self.assertRaises(InvalidDocumentException):
self.ref.resolve_parameter_refs(input, {})

def test_mapping_not_string(self):
Expand Down
9 changes: 5 additions & 4 deletions tests/swagger/test_swagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from parameterized import parameterized, param

from samtranslator.swagger.swagger import SwaggerEditor
from samtranslator.model.exceptions import InvalidDocumentException

_X_INTEGRATION = "x-amazon-apigateway-integration"
_X_ANY_METHOD = 'x-amazon-apigateway-any-method'
Expand Down Expand Up @@ -193,7 +194,7 @@ def test_must_raise_non_dict_path_values(self):
path = "/badpath"
method = "get"

with self.assertRaises(ValueError):
with self.assertRaises(InvalidDocumentException):
self.editor.add_path(path, method)

def test_must_skip_existing_path(self):
Expand Down Expand Up @@ -339,7 +340,7 @@ def test_must_add_credentials_to_the_integration(self):

self.editor.add_lambda_integration(path, method, integration_uri, None, api_auth_config)
actual = self.editor.swagger["paths"][path][method][_X_INTEGRATION]['credentials']
self.assertEquals(expected, actual)
self.assertEqual(expected, actual)

def test_must_add_credentials_to_the_integration_overrides(self):
path = "/newpath"
Expand All @@ -356,7 +357,7 @@ def test_must_add_credentials_to_the_integration_overrides(self):

self.editor.add_lambda_integration(path, method, integration_uri, method_auth_config, api_auth_config)
actual = self.editor.swagger["paths"][path][method][_X_INTEGRATION]['credentials']
self.assertEquals(expected, actual)
self.assertEqual(expected, actual)


class TestSwaggerEditor_iter_on_path(TestCase):
Expand Down Expand Up @@ -430,7 +431,7 @@ def test_must_skip_existing_path(self):
def test_must_fail_with_bad_values_for_path(self):
path = "/bad"

with self.assertRaises(ValueError):
with self.assertRaises(InvalidDocumentException):
self.editor.add_cors(path, "origins", "headers", "methods")

def test_must_fail_for_invalid_allowed_origin(self):
Expand Down
10 changes: 10 additions & 0 deletions tests/translator/input/error_api_invalid_path.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Resources:
ApiWithInvalidPath:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
Cors: "'*'"
DefinitionBody:
swagger: 2.0
paths:
/foo:
Loading