Repository: getmoto/moto

Issue:
`batch.utils.lowercase_first_key()` should be recursive
When creating a job definition using cloudformation, I noticed that the `batch.utils.lowercase_first_key()` function does not recurse into nested dictionaries or lists of dictionaries. This is required, for example, by the "environment," "mountPoints," and "linuxParameters" values passed to the `containerProperties` parameter of boto's [register_job_definition](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/batch.html#Batch.Client.register_job_definition) function. This in turn leads to empty environment variables in the container properties when creating a job definition using CloudFormation.

## How to reproduce this issue

Use the following CloudFormation json template
```json
{
    "AWSTemplateFormatVersion" : "2010-09-09",
        "Description" : "Test template for bug reproduction",
        "Parameters" : {
            "JdName" : {
                "Type" : "String",
                "Description" : "Name of the job definition.",
                "MinLength" : "1",
                "MaxLength" : "100",
                "ConstraintDescription" : "The job definition name must be between 1 and 50 characters"
            },
            "JdvCpus" : {
                "Type" : "Number",
                "Default" : "1",
                "Description" : "Number of virtual CPUs required for the job definition. Default=1",
                "MinValue" : "0",
                "ConstraintDescription" : "vCpus must be greater than zero."
            },
            "JdMemory" : {
                "Type" : "Number",
                "Default" : "8000",
                "Description" : "Memory (MiB) required for the job definition. Default=8000",
                "MinValue" : "0",
                "ConstraintDescription" : "Memory must be greater than zero."
            },
            "JdRetries" : {
                "Type" : "Number",
                "Default" : "1",
                "Description" : "Number of times a job can be moved back to 'RUNNABLE' status. Default=1",
                "MinValue" : "1",
                "MaxValue" : "10",
                "ConstraintDescription" : "The number of retries must be between 1 and 10."
            }
        },
        "Resources" : {
            "JobDefinition" : {
                "Type" : "AWS::Batch::JobDefinition",
                "Properties" : {
                    "JobDefinitionName" : { "Ref" : "JdName" },
                    "Type" : "container",
                    "ContainerProperties" : {
                        "Image" : "ubuntu",
                        "Vcpus" : { "Ref" : "JdvCpus" },
                        "Memory" : { "Ref" : "JdMemory" },
                        "Environment" : [
                            {
                                "Name" : "ENV_VAR_1",
                                "Value" : "ENV_VAL_1"
                            }
                        ]
                    },
                    "RetryStrategy" : {
                        "Attempts" : { "Ref" : "JdRetries" }
                    }
                }
            }
        },
        "Outputs" : {
            "JobDefinition": {
                "Value" : { "Ref" : "JobDefinition" }
            }
        }
}
```
and the following MWE python code
```python
import boto3

from moto import mock_batch, mock_cloudformation

@mock_cloudformation
@mock_batch
def test_job_def_environment_with_cloudformation():
    cf = boto3.client('cloudformation')

    with open("mwe.template", "r") as fp:
        template_body = fp.read()

    params = [
        {
            "ParameterKey": "JdName",
            "ParameterValue": "test_job_def"
        },
        {
            "ParameterKey": "JdvCpus",
            "ParameterValue": str(123)
        },
        {
            "ParameterKey": "JdMemory",
            "ParameterValue": str(123)
        },
        {
            "ParameterKey": "JdRetries",
            "ParameterValue": str(3)
        },
    ]

    response = cf.create_stack(
        StackName='job_definition_stack',
        TemplateBody=template_body,
        Parameters=params,
        Capabilities=["CAPABILITY_NAMED_IAM"],
    )

    stack_id = response.get('StackId')
    
    response = cf.describe_stacks(
        StackName=stack_id
    )

    stack_out = response.get('Stacks')[0]['Outputs']

    jd_arn = stack_out[0].get('OutputValue')

    batch = boto3.client('batch')
    response = batch.describe_job_definitions(
        jobDefinitions=[jd_arn]
    )
    job_def = response.get("jobDefinitions")[0]
    job_def_env = job_def["containerProperties"]["environment"]

    assert all([env for env in job_def_env]), "Environment variables are empty."
    assert('ENV_VAR_1' in [e['name'] for e in job_def_env])
    assert('ENV_VAR_2' in [e['name'] for e in job_def_env])
    assert('ENV_VAL_1' in [e['value'] for e in job_def_env])
    assert('ENV_VAL_2' in [e['value'] for e in job_def_env])
```

## Expected behavior
Tests should pass. No assertion errors should be raised.

## What happens instead
I get this traceback:
```bash
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-3-82b538f9bd9c> in <module>
----> 1 mwe.test_job_def_environment_with_cloudformation()

~/projects/oss_third_party/moto/moto/core/models.py in wrapper(*args, **kwargs)
     97             self.start(reset=reset)
     98             try:
---> 99                 result = func(*args, **kwargs)
    100             finally:
    101                 self.stop()

~/projects/oss_third_party/moto/moto/core/models.py in wrapper(*args, **kwargs)
     97             self.start(reset=reset)
     98             try:
---> 99                 result = func(*args, **kwargs)
    100             finally:
    101                 self.stop()

~/projects/oss_third_party/moto/mwe.py in test_job_def_environment_with_cloudformation()
     55     print(job_def_env)
     56
---> 57     assert all([env for env in job_def_env]), "Environment variables are empty."
     58     assert('ENV_VAR_1' in [e['name'] for e in job_def_env])
     59     assert('ENV_VAL_1' in [e['value'] for e in job_def_env])

AssertionError: Environment variables are empty.
```

## What version of moto am I using

The command `pip list | grep oto` yields
```bash
boto               2.49.0
boto3              1.14.20
botocore           1.17.20
moto               1.3.15.dev959
```

I will submit a PR that fixes this.


Discussion:

Resolving patch:
diff --git a/moto/batch/utils.py b/moto/batch/utils.py
--- a/moto/batch/utils.py
+++ b/moto/batch/utils.py
@@ -21,6 +21,14 @@ def lowercase_first_key(some_dict):
     new_dict = {}
     for key, value in some_dict.items():
         new_key = key[0].lower() + key[1:]
-        new_dict[new_key] = value
+        try:
+            if isinstance(value, dict):
+                new_dict[new_key] = lowercase_first_key(value)
+            elif all([isinstance(v, dict) for v in value]):
+                new_dict[new_key] = [lowercase_first_key(v) for v in value]
+            else:
+                new_dict[new_key] = value
+        except TypeError:
+            new_dict[new_key] = value
 
     return new_dict


Test patch:
diff --git a/tests/test_batch/test_cloudformation.py b/tests/test_batch/test_cloudformation.py
--- a/tests/test_batch/test_cloudformation.py
+++ b/tests/test_batch/test_cloudformation.py
@@ -234,6 +234,7 @@ def test_create_job_def_cf():
                         "Vcpus": 2,
                         "Memory": 2000,
                         "Command": ["echo", "Hello world"],
+                        "LinuxParameters": {"Devices": [{"HostPath": "test-path"}]},
                     },
                     "RetryStrategy": {"Attempts": 1},
                 },
@@ -262,3 +263,17 @@ def test_create_job_def_cf():
     job_def_resource["PhysicalResourceId"].startswith("arn:aws:batch:")
     job_def_resource["PhysicalResourceId"].should.contain("test_stack-JobDef")
     job_def_resource["PhysicalResourceId"].should.contain("job-definition/")
+
+    # Test the linux parameter device host path
+    # This ensures that batch is parsing the parameter dictionaries
+    # correctly by recursively converting the first character of all
+    # dict keys to lowercase.
+    batch_conn = boto3.client("batch", DEFAULT_REGION)
+    response = batch_conn.describe_job_definitions(
+        jobDefinitions=[job_def_resource["PhysicalResourceId"]]
+    )
+    job_def_linux_device_host_path = response.get("jobDefinitions")[0][
+        "containerProperties"
+    ]["linuxParameters"]["devices"][0]["hostPath"]
+
+    job_def_linux_device_host_path.should.equal("test-path")