Repository: getmoto/moto

Issue:
Possible "NUMBER"-type parameter validation error in CloudFormation
When creating a JobDefinition using Cloudformation with a job definition memory parameter, I get a type error for the memory passed into the parameters of `moto.batch.JobDefinition`. Apologies for the rather long trracebacks, but I'm erring on the side of too much information rather than too little.

## How to reproduce
To reproduce, use the following MWE CloudFormation Template:
```json
{
    "AWSTemplateFormatVersion" : "2010-09-09",
        "Description" : "AWS CloudFormation Template: This template is to demonstrate a potential moto bug. It builds a job definition.",
        "Parameters" : {
            "JdMemory" : {
                "Type" : "Number",
                "Default" : "8000",
                "Description" : "Memory (MiB) required for the job definition. Default=8000",
                "MinValue" : "0",
                "ConstraintDescription" : "Memory must be greater than zero."
            }
        },
        "Resources" : {
            "JobDefinition" : {
                "Type" : "AWS::Batch::JobDefinition",
                "Properties" : {
                    "JobDefinitionName" : "testJobDef",
                    "Type" : "container",
                    "ContainerProperties" : {
                        "Image" : "testDockerImage",
                        "Vcpus" : "2",
                        "Memory" : { "Ref" : "JdMemory" },
                        "Command" : [],
                        "User" : "test_user",
                        "Environment" : []
                    },
                    "RetryStrategy" : {
                        "Attempts" : "3"
                    }
                }
            }
        },
        "Outputs" : {
            "JobDefinition": {
                "Value" : { "Ref" : "JobDefinition" }
            }
        }
}
```
and use the following MWE test function
```python
import boto3
import json
import os
from moto import mock_cloudformation, mock_batch


@mock_batch
@mock_cloudformation
def test_jobdef():
    memory = 8000
    params = [{"ParameterKey": "JdMemory", "ParameterValue": str(memory)}]

    template_path = os.path.abspath(
        os.path.join(
            os.path.dirname(__file__), "batch-environment.template"
        )
    )

    with open(template_path, "r") as fp:
        template_body = fp.read()

    client = boto3.client("cloudformation")
    response = client.create_stack(
        StackName="testStack",
        TemplateBody=template_body,
        Parameters=params,
    )

    stack_id = response.get("StackId")
    return stack_id
```

## Expected behavior
Return the `stack_id` from the mocked stack containing a job definition

## What actually happens
The full traceback I get is:
```
~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/models.py in wrapper(*args, **kwargs)
     86             self.start(reset=reset)
     87             try:
---> 88                 result = func(*args, **kwargs)
     89             finally:
     90                 self.stop()

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/models.py in wrapper(*args, **kwargs)
     86             self.start(reset=reset)
     87             try:
---> 88                 result = func(*args, **kwargs)
     89             finally:
     90                 self.stop()

~/projects/oss_third_party/moto_bug/test_bug.py in test_jobdef()
     30         TemplateBody=template_body,
     31         Parameters=params,
---> 32         Capabilities=["CAPABILITY_NAMED_IAM"],
     33     )
     34

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/client.py in _api_call(self, *args, **kwargs)
    314                     "%s() only accepts keyword arguments." % py_operation_name)
    315             # The "self" in this scope is referring to the BaseClient.
--> 316             return self._make_api_call(operation_name, kwargs)
    317
    318         _api_call.__name__ = str(py_operation_name)

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/client.py in _make_api_call(self, operation_name, api_params)
    620         else:
    621             http, parsed_response = self._make_request(
--> 622                 operation_model, request_dict, request_context)
    623
    624         self.meta.events.emit(

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/client.py in _make_request(self, operation_model, request_dict, request_context)
    639     def _make_request(self, operation_model, request_dict, request_context):
    640         try:
--> 641             return self._endpoint.make_request(operation_model, request_dict)
    642         except Exception as e:
    643             self.meta.events.emit(

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/endpoint.py in make_request(self, operation_model, request_dict)
    100         logger.debug("Making request for %s with params: %s",
    101                      operation_model, request_dict)
--> 102         return self._send_request(request_dict, operation_model)
    103
    104     def create_request(self, params, operation_model=None):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/endpoint.py in _send_request(self, request_dict, operation_model)
    135             request, operation_model, context)
    136         while self._needs_retry(attempts, operation_model, request_dict,
--> 137                                 success_response, exception):
    138             attempts += 1
    139             # If there is a stream associated with the request, we need

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/endpoint.py in _needs_retry(self, attempts, operation_model, request_dict, response, caught_exception)
    254             event_name, response=response, endpoint=self,
    255             operation=operation_model, attempts=attempts,
--> 256             caught_exception=caught_exception, request_dict=request_dict)
    257         handler_response = first_non_none_response(responses)
    258         if handler_response is None:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/hooks.py in emit(self, event_name, **kwargs)
    354     def emit(self, event_name, **kwargs):
    355         aliased_event_name = self._alias_event_name(event_name)
--> 356         return self._emitter.emit(aliased_event_name, **kwargs)
    357
    358     def emit_until_response(self, event_name, **kwargs):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/hooks.py in emit(self, event_name, **kwargs)
    226                  handlers.
    227         """
--> 228         return self._emit(event_name, kwargs)
    229
    230     def emit_until_response(self, event_name, **kwargs):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/hooks.py in _emit(self, event_name, kwargs, stop_on_response)
    209         for handler in handlers_to_call:
    210             logger.debug('Event %s: calling handler %s', event_name, handler)
--> 211             response = handler(**kwargs)
    212             responses.append((handler, response))
    213             if stop_on_response and response is not None:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/retryhandler.py in __call__(self, attempts, response, caught_exception, **kwargs)
    181
    182         """
--> 183         if self._checker(attempts, response, caught_exception):
    184             result = self._action(attempts=attempts)
    185             logger.debug("Retry needed, action of: %s", result)

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/retryhandler.py in __call__(self, attempt_number, response, caught_exception)
    249     def __call__(self, attempt_number, response, caught_exception):
    250         should_retry = self._should_retry(attempt_number, response,
--> 251                                           caught_exception)
    252         if should_retry:
    253             if attempt_number >= self._max_attempts:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/retryhandler.py in _should_retry(self, attempt_number, response, caught_exception)
    267                 attempt_number < self._max_attempts:
    268             try:
--> 269                 return self._checker(attempt_number, response, caught_exception)
    270             except self._retryable_exceptions as e:
    271                 logger.debug("retry needed, retryable exception caught: %s",

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/retryhandler.py in __call__(self, attempt_number, response, caught_exception)
    315         for checker in self._checkers:
    316             checker_response = checker(attempt_number, response,
--> 317                                        caught_exception)
    318             if checker_response:
    319                 return checker_response

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/retryhandler.py in __call__(self, attempt_number, response, caught_exception)
    221         elif caught_exception is not None:
    222             return self._check_caught_exception(
--> 223                 attempt_number, caught_exception)
    224         else:
    225             raise ValueError("Both response and caught_exception are None.")

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/retryhandler.py in _check_caught_exception(self, attempt_number, caught_exception)
    357         # the MaxAttemptsDecorator is not interested in retrying the exception
    358         # then this exception just propogates out past the retry code.
--> 359         raise caught_exception

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/endpoint.py in _do_get_response(self, request, operation_model)
    195             service_id = operation_model.service_model.service_id.hyphenize()
    196             event_name = 'before-send.%s.%s' % (service_id, operation_model.name)
--> 197             responses = self._event_emitter.emit(event_name, request=request)
    198             http_response = first_non_none_response(responses)
    199             if http_response is None:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/hooks.py in emit(self, event_name, **kwargs)
    354     def emit(self, event_name, **kwargs):
    355         aliased_event_name = self._alias_event_name(event_name)
--> 356         return self._emitter.emit(aliased_event_name, **kwargs)
    357
    358     def emit_until_response(self, event_name, **kwargs):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/hooks.py in emit(self, event_name, **kwargs)
    226                  handlers.
    227         """
--> 228         return self._emit(event_name, kwargs)
    229
    230     def emit_until_response(self, event_name, **kwargs):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/hooks.py in _emit(self, event_name, kwargs, stop_on_response)
    209         for handler in handlers_to_call:
    210             logger.debug('Event %s: calling handler %s', event_name, handler)
--> 211             response = handler(**kwargs)
    212             responses.append((handler, response))
    213             if stop_on_response and response is not None:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/models.py in __call__(self, event_name, request, **kwargs)
    270                     request.headers[header] = value.decode("utf-8")
    271             status, headers, body = response_callback(
--> 272                 request, request.url, request.headers
    273             )
    274             body = MockRawResponse(body)

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/responses.py in dispatch(cls, *args, **kwargs)
    195     @classmethod
    196     def dispatch(cls, *args, **kwargs):
--> 197         return cls()._dispatch(*args, **kwargs)
    198
    199     def setup_class(self, request, full_url, headers):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/responses.py in _dispatch(self, request, full_url, headers)
    293     def _dispatch(self, request, full_url, headers):
    294         self.setup_class(request, full_url, headers)
--> 295         return self.call_action()
    296
    297     def uri_to_regexp(self, uri):

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/responses.py in call_action(self)
    378             method = getattr(self, action)
    379             try:
--> 380                 response = method()
    381             except HTTPException as http_error:
    382                 response = http_error.description, dict(status=http_error.code)

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/responses.py in create_stack(self)
     68             notification_arns=stack_notification_arns,
     69             tags=tags,
---> 70             role_arn=role_arn,
     71         )
     72         if self.request_json:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/models.py in create_stack(self, name, template, parameters, region_name, notification_arns, tags, role_arn, create_change_set)
    541             role_arn=role_arn,
    542             cross_stack_resources=self.exports,
--> 543             create_change_set=create_change_set,
    544         )
    545         self.stacks[stack_id] = new_stack

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/models.py in __init__(self, stack_id, name, template, parameters, region_name, notification_arns, tags, role_arn, cross_stack_resources, create_change_set)
    236         self.description = self.template_dict.get("Description")
    237         self.cross_stack_resources = cross_stack_resources or {}
--> 238         self.resource_map = self._create_resource_map()
    239         self.output_map = self._create_output_map()
    240         self._add_stack_event("CREATE_COMPLETE")

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/models.py in _create_resource_map(self)
    251             self.cross_stack_resources,
    252         )
--> 253         resource_map.create()
    254         return resource_map
    255

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/parsing.py in create(self)
    527         )
    528         for resource in self.resources:
--> 529             if isinstance(self[resource], ec2_models.TaggedEC2Resource):
    530                 self.tags["aws:cloudformation:logical-id"] = resource
    531                 ec2_models.ec2_backends[self._region_name].create_tags(

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/parsing.py in __getitem__(self, key)
    451                 raise KeyError(resource_logical_id)
    452             new_resource = parse_and_create_resource(
--> 453                 resource_logical_id, resource_json, self, self._region_name
    454             )
    455             if new_resource is not None:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/cloudformation/parsing.py in parse_and_create_resource(logical_id, resource_json, resources_map, region_name)
    330     resource_class, resource_json, resource_name = resource_tuple
    331     resource = resource_class.create_from_cloudformation_json(
--> 332         resource_name, resource_json, region_name
    333     )
    334     resource.type = resource_type

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/batch/models.py in create_from_cloudformation_json(cls, resource_name, cloudformation_json, region_name)
    277             _type="container",
    278             retry_strategy=lowercase_first_key(properties["RetryStrategy"]),
--> 279             container_properties=lowercase_first_key(properties["ContainerProperties"]),
    280         )
    281

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/batch/models.py in register_job_definition(self, def_name, parameters, _type, retry_strategy, container_properties)
   1137                 container_properties,
   1138                 region_name=self.region_name,
-> 1139                 retry_strategy=retry_strategy,
   1140             )
   1141         else:

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/batch/models.py in __init__(self, name, parameters, _type, container_properties, region_name, revision, retry_strategy)
    189         self.parameters = parameters
    190
--> 191         self._validate()
    192         self._update_arn()
    193

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/batch/models.py in _validate(self)
    214         if "memory" not in self.container_properties:
    215             raise ClientException("containerProperties must contain memory")
--> 216         if self.container_properties["memory"] < 4:
    217             raise ClientException("container memory limit must be greater than 4")
    218

TypeError: '<' not supported between instances of 'str' and 'int'
```

## What version of moto am I using
Installed through pip. The command `pip list | grep oto` yields
```
boto               2.49.0
boto3              1.14.16
botocore           1.17.16
moto               1.3.14
```

## How it might be fixed
I tried amending my code so that an int is passed as the memory parameter value:
```
params = [{"ParameterKey": "JdMemory", "ParameterValue": str(memory)}]
```
However, this raises another issue:
```
~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/models.py in wrapper(*args, **kwargs)
     86             self.start(reset=reset)
     87             try:
---> 88                 result = func(*args, **kwargs)
     89             finally:
     90                 self.stop()

~/miniconda3/envs/scratch/lib/python3.7/site-packages/moto/core/models.py in wrapper(*args, **kwargs)
     86             self.start(reset=reset)
     87             try:
---> 88                 result = func(*args, **kwargs)
     89             finally:
     90                 self.stop()

~/projects/oss_third_party/moto_bug/test_bug.py in test_jobdef()
     30         TemplateBody=template_body,
     31         Parameters=params,
---> 32         Capabilities=["CAPABILITY_NAMED_IAM"],
     33     )
     34

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/client.py in _api_call(self, *args, **kwargs)
    314                     "%s() only accepts keyword arguments." % py_operation_name)
    315             # The "self" in this scope is referring to the BaseClient.
--> 316             return self._make_api_call(operation_name, kwargs)
    317
    318         _api_call.__name__ = str(py_operation_name)

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/client.py in _make_api_call(self, operation_name, api_params)
    606         }
    607         request_dict = self._convert_to_request_dict(
--> 608             api_params, operation_model, context=request_context)
    609
    610         service_id = self._service_model.service_id.hyphenize()

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/client.py in _convert_to_request_dict(self, api_params, operation_model, context)
    654             api_params, operation_model, context)
    655         request_dict = self._serializer.serialize_to_request(
--> 656             api_params, operation_model)
    657         if not self._client_config.inject_host_prefix:
    658             request_dict.pop('host_prefix', None)

~/miniconda3/envs/scratch/lib/python3.7/site-packages/botocore/validate.py in serialize_to_request(self, parameters, operation_model)
    295                                                     operation_model.input_shape)
    296             if report.has_errors():
--> 297                 raise ParamValidationError(report=report.generate_report())
    298         return self._serializer.serialize_to_request(parameters,
    299                                                      operation_model)

ParamValidationError: Parameter validation failed:
Invalid type for parameter Parameters[0].ParameterValue, value: 8000, type: <class 'int'>, valid types: <class 'str'>
```
So the local fix to my MWE isn't really satisfactory. The immediate issue could probably be fixed by making the substitution
```
if int(self.container_properties["memory"]) < 4:
```
in [this line](https://github.com/spulec/moto/blob/master/moto/batch/models.py#L216) of `moto.batch.models`.
However, I don't think that will fix the root cause of the problem. I believe the root cause is that CloudFormation parameters of type "NUMBER" are not being converted into int or float inside of
[`moto.cloudformation.parsing`](https://github.com/spulec/moto/blob/master/moto/cloudformation/parsing.py) before being passed onto the stack's constituent resources.

I'm happy to try to fix this and submit a PR but I'm not really sure where to start and would need a few suggestions. or if it would be quicker for a more experiences moto developer to address, that's fine too.


Discussion:
Hi @richford, thanks for raising this! Will mark it as an improvement.

Moto is very much a community driven project, so any PR's are welcome. There are some basic guidelines on how to get started here: [CONTRIBUTING.md](https://github.com/spulec/moto/blob/master/CONTRIBUTING.md).

For this particular issue, it looks like we're not doing anything special for NUMBER-parameters at the moment. The fix might be as simple as adding another if-statement here, to convert the value into ints: https://github.com/spulec/moto/blob/master/moto/cloudformation/parsing.py#L559



Resolving patch:
diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py
--- a/moto/cloudformation/parsing.py
+++ b/moto/cloudformation/parsing.py
@@ -560,6 +560,23 @@ def load_parameters(self):
                 if value_type == "CommaDelimitedList" or value_type.startswith("List"):
                     value = value.split(",")
 
+                def _parse_number_parameter(num_string):
+                    """CloudFormation NUMBER types can be an int or float.
+                    Try int first and then fall back to float if that fails
+                    """
+                    try:
+                        return int(num_string)
+                    except ValueError:
+                        return float(num_string)
+
+                if value_type == "List<Number>":
+                    # The if statement directly above already converted
+                    # to a list. Now we convert each element to a number
+                    value = [_parse_number_parameter(v) for v in value]
+
+                if value_type == "Number":
+                    value = _parse_number_parameter(value)
+
                 if parameter_slot.get("NoEcho"):
                     self.no_echo_parameter_keys.append(key)
 


Test patch:
diff --git a/tests/test_cloudformation/test_stack_parsing.py b/tests/test_cloudformation/test_stack_parsing.py
--- a/tests/test_cloudformation/test_stack_parsing.py
+++ b/tests/test_cloudformation/test_stack_parsing.py
@@ -67,6 +67,8 @@
 parameters = {
     "Parameters": {
         "Param": {"Type": "String"},
+        "NumberParam": {"Type": "Number"},
+        "NumberListParam": {"Type": "List<Number>"},
         "NoEchoParam": {"Type": "String", "NoEcho": True},
     }
 }
@@ -303,12 +305,23 @@ def test_parse_stack_with_parameters():
         stack_id="test_id",
         name="test_stack",
         template=parameters_template_json,
-        parameters={"Param": "visible value", "NoEchoParam": "hidden value"},
+        parameters={
+            "Param": "visible value",
+            "NumberParam": "42",
+            "NumberListParam": "42,3.14159",
+            "NoEchoParam": "hidden value",
+        },
         region_name="us-west-1",
     )
 
     stack.resource_map.no_echo_parameter_keys.should.have("NoEchoParam")
     stack.resource_map.no_echo_parameter_keys.should_not.have("Param")
+    stack.resource_map.no_echo_parameter_keys.should_not.have("NumberParam")
+    stack.resource_map.no_echo_parameter_keys.should_not.have("NumberListParam")
+    stack.resource_map.resolved_parameters["NumberParam"].should.equal(42)
+    stack.resource_map.resolved_parameters["NumberListParam"].should.equal(
+        [42, 3.14159]
+    )
 
 
 def test_parse_equals_condition():