Repository: cloudtools/troposphere
Pull request #1913 (2021-06-07 09:27:03)

[batch] Update required properties
Some properties are no longer required (at least when using Fargate). Source:

- `ContainerProperties.Memory`: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-batch-jobdefinition-containerproperties.html#cfn-batch-jobdefinition-containerproperties-memory
- `ContainerProperties.Vcpus`: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-batch-jobdefinition-containerproperties.html#cfn-batch-jobdefinition-containerproperties-vcpus
- `ComputeEnvironment.ServiceRole`: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-batch-computeenvironment.html#cfn-batch-computeenvironment-servicerole

I've successfully deployed patched resources without those properties.


Patch:
diff --git a/scripts/cfn2py b/scripts/cfn2py
index 1a3a1c01..5ef6547c 100755
--- a/scripts/cfn2py
+++ b/scripts/cfn2py
@@ -56,7 +56,7 @@ def do_header(d):
     print('from troposphere import Base64, Select, FindInMap, GetAtt')
     print('from troposphere import GetAZs, Join, Output, If, And, Not')
     print('from troposphere import Or, Equals, Condition')
-    print('from troposphere import Parameter, Ref, Tags, Template')
+    print('from troposphere import Parameter, Ref, Tags, Template, Export')
     print('from troposphere.cloudformation import Init')
     print('from troposphere.cloudfront import Distribution')
     print('from troposphere.cloudfront import DistributionConfig')
@@ -353,6 +353,11 @@ def do_outputs(d):
         for pk, pv in v.items():
             if isinstance(pv, str):
                 print('    {}="{}",'.format(pk, pv))
+            elif pk == 'Export':
+                ov = output_value(pv)
+                if ov[0] == '{': # Is a dictionary
+                    ov = json.loads(ov)['Name']
+                print('    {}={}("{}")'.format(pk, pk, ov))
             else:
                 print('    {}={},'.format(pk, output_value(pv)))
         print("))")
diff --git a/troposphere/apigatewayv2.py b/troposphere/apigatewayv2.py
index 5cb5f3a2..40e17304 100644
--- a/troposphere/apigatewayv2.py
+++ b/troposphere/apigatewayv2.py
@@ -208,7 +208,7 @@ class Integration(AWSObject):
         "CredentialsArn": (str, False),
         "Description": (str, False),
         "IntegrationMethod": (str, False),
-        "IntegrationSubType": (str, False),
+        "IntegrationSubtype": (str, False),
         "IntegrationType": (validate_integration_type, True),
         "IntegrationUri": (str, False),
         "PassthroughBehavior": (validate_passthrough_behavior, False),
diff --git a/troposphere/batch.py b/troposphere/batch.py
index e81304ca..bc202b54 100644
--- a/troposphere/batch.py
+++ b/troposphere/batch.py
@@ -169,7 +169,7 @@ class ContainerProperties(AWSProperty):
         "JobRoleArn": (str, False),
         "LinuxParameters": (LinuxParameters, False),
         "LogConfiguration": (LogConfiguration, False),
-        "Memory": (positive_integer, True),
+        "Memory": (positive_integer, False),
         "MountPoints": ([MountPoints], False),
         "Privileged": (boolean, False),
         "ReadonlyRootFilesystem": (boolean, False),
@@ -177,7 +177,7 @@ class ContainerProperties(AWSProperty):
         "Secrets": ([Secret], False),
         "Ulimits": ([Ulimit], False),
         "User": (str, False),
-        "Vcpus": (positive_integer, True),
+        "Vcpus": (positive_integer, False),
         "Volumes": ([Volumes], False),
     }
 
@@ -256,7 +256,7 @@ class ComputeEnvironment(AWSObject):
     props = {
         "ComputeEnvironmentName": (str, False),
         "ComputeResources": (ComputeResources, True),
-        "ServiceRole": (str, True),
+        "ServiceRole": (str, False),
         "State": (validate_environment_state, False),
         "Tags": (dict, False),
         "Type": (str, True),
diff --git a/troposphere/constants.py b/troposphere/constants.py
index 76cb332d..d9912288 100644
--- a/troposphere/constants.py
+++ b/troposphere/constants.py
@@ -651,6 +651,14 @@ DB_R5_12XLARGE = "db.r5.12xlarge"
 DB_R5_16XLARGE = "db.r5.16xlarge"
 DB_R5_24XLARGE = "db.r5.24xlarge"
 
+DB_R6G_LARGE = "db.r6g.large"
+DB_R6G_XLARGE = "db.r6g.xlarge"
+DB_R6G_2XLARGE = "db.r6g.2xlarge"
+DB_R6G_4XLARGE = "db.r6g.4xlarge"
+DB_R6G_8XLARGE = "db.r6g.8xlarge"
+DB_R6G_12XLARGE = "db.r6g.12xlarge"
+DB_R6G_16XLARGE = "db.r6g.16xlarge"
+
 DB_X1_16XLARGE = "db.x1.16xlarge"
 DB_X1_32XLARGE = "db.x1.32xlarge"
 
@@ -724,6 +732,13 @@ ELASTICSEARCH_M5_2XLARGE = "m5.2xlarge.elasticsearch"
 ELASTICSEARCH_M5_4XLARGE = "m5.4xlarge.elasticsearch"
 ELASTICSEARCH_M5_12XLARGE = "m5.12xlarge.elasticsearch"
 
+ELASTICSEARCH_M6G_LARGE = "m6g.large.elasticsearch"
+ELASTICSEARCH_M6G_XLARGE = "m6g.xlarge.elasticsearch"
+ELASTICSEARCH_M6G_2XLARGE = "m6g.2xlarge.elasticsearch"
+ELASTICSEARCH_M6G_4XLARGE = "m6g.4xlarge.elasticsearch"
+ELASTICSEARCH_M6G_8XLARGE = "m6g.8xlarge.elasticsearch"
+ELASTICSEARCH_M6G_12XLARGE = "m6g.12xlarge.elasticsearch"
+
 ELASTICSEARCH_C4_LARGE = "c4.large.elasticsearch"
 ELASTICSEARCH_C4_XLARGE = "c4.xlarge.elasticsearch"
 ELASTICSEARCH_C4_2XLARGE = "c4.2xlarge.elasticsearch"
@@ -737,6 +752,13 @@ ELASTICSEARCH_C5_4XLARGE = "c5.4xlarge.elasticsearch"
 ELASTICSEARCH_C5_9XLARGE = "c5.9xlarge.elasticsearch"
 ELASTICSEARCH_C5_18XLARGE = "c5.18xlarge.elasticsearch"
 
+ELASTICSEARCH_C6G_LARGE = "c6g.large.elasticsearch"
+ELASTICSEARCH_C6G_XLARGE = "c6g.xlarge.elasticsearch"
+ELASTICSEARCH_C6G_2XLARGE = "c6g.2xlarge.elasticsearch"
+ELASTICSEARCH_C6G_4XLARGE = "c6g.4xlarge.elasticsearch"
+ELASTICSEARCH_C6G_8XLARGE = "c6g.8xlarge.elasticsearch"
+ELASTICSEARCH_C6G_12XLARGE = "c6g.12xlarge.elasticsearch"
+
 ELASTICSEARCH_R3_LARGE = "r3.large.elasticsearch"
 ELASTICSEARCH_R3_XLARGE = "r3.xlarge.elasticsearch"
 ELASTICSEARCH_R3_2XLARGE = "r3.2xlarge.elasticsearch"
@@ -756,6 +778,21 @@ ELASTICSEARCH_R5_2XLARGE = "r5.2xlarge.elasticsearch"
 ELASTICSEARCH_R5_4XLARGE = "r5.4xlarge.elasticsearch"
 ELASTICSEARCH_R5_12XLARGE = "r5.12xlarge.elasticsearch"
 
+ELASTICSEARCH_R6G_LARGE = "r6g.large.elasticsearch"
+ELASTICSEARCH_R6G_XLARGE = "r6g.xlarge.elasticsearch"
+ELASTICSEARCH_R6G_2XLARGE = "r6g.2xlarge.elasticsearch"
+ELASTICSEARCH_R6G_4XLARGE = "r6g.4xlarge.elasticsearch"
+ELASTICSEARCH_R6G_8XLARGE = "r6g.8xlarge.elasticsearch"
+ELASTICSEARCH_R6G_12XLARGE = "r6g.12xlarge.elasticsearch"
+
+ELASTICSEARCH_R6GD_LARGE = "r6gd.large.elasticsearch"
+ELASTICSEARCH_R6GD_XLARGE = "r6gd.xlarge.elasticsearch"
+ELASTICSEARCH_R6GD_2XLARGE = "r6gd.2xlarge.elasticsearch"
+ELASTICSEARCH_R6GD_4XLARGE = "r6gd.4xlarge.elasticsearch"
+ELASTICSEARCH_R6GD_8XLARGE = "r6gd.8xlarge.elasticsearch"
+ELASTICSEARCH_R6GD_12XLARGE = "r6gd.12xlarge.elasticsearch"
+ELASTICSEARCH_R6GD_16XLARGE = "r6gd.16xlarge.elasticsearch"
+
 ELASTICSEARCH_I2_XLARGE = "i2.xlarge.elasticsearch"
 ELASTICSEARCH_I2_2XLARGE = "i2.2xlarge.elasticsearch"
 
diff --git a/troposphere/ec2.py b/troposphere/ec2.py
index c066a4dc..6def9da5 100644
--- a/troposphere/ec2.py
+++ b/troposphere/ec2.py
@@ -737,6 +737,7 @@ class Volume(AWSObject):
         "Size": (positive_integer, False),
         "SnapshotId": (str, False),
         "Tags": ((Tags, list), False),
+        "Throughput": (positive_integer, False),
         "VolumeType": (str, False),
     }
 
@@ -1251,6 +1252,7 @@ class TransitGateway(AWSObject):
         "DefaultRouteTablePropagation": (str, False),
         "Description": (str, False),
         "DnsSupport": (str, False),
+        "MulticastSupport": (str, False),
         "Tags": ((Tags, list), False),
         "VpnEcmpSupport": (str, False),
     }
diff --git a/troposphere/ecs.py b/troposphere/ecs.py
index b66efe64..de78d0d5 100644
--- a/troposphere/ecs.py
+++ b/troposphere/ecs.py
@@ -504,12 +504,19 @@ class ProxyConfiguration(AWSProperty):
     }
 
 
+class EphemeralStorage(AWSProperty):
+    props = {
+        "SizeInGiB": (integer_range(21, 200), False),
+    }
+
+
 class TaskDefinition(AWSObject):
     resource_type = "AWS::ECS::TaskDefinition"
 
     props = {
         "ContainerDefinitions": ([ContainerDefinition], False),
         "Cpu": (str, False),
+        "EphemeralStorage": (EphemeralStorage, False),
         "ExecutionRoleArn": (str, False),
         "Family": (str, False),
         "InferenceAccelerators": ([InferenceAccelerator], False),
diff --git a/troposphere/helpers/userdata.py b/troposphere/helpers/userdata.py
index 7bef5ce5..1c3c9034 100644
--- a/troposphere/helpers/userdata.py
+++ b/troposphere/helpers/userdata.py
@@ -1,6 +1,6 @@
 #!/usr/bin/python
 
-from troposphere import Base64, Join
+from troposphere import Base64, Join, Sub
 
 
 def from_file(filepath, delimiter="", blanklines=False):
@@ -34,3 +34,22 @@ def from_file(filepath, delimiter="", blanklines=False):
         raise IOError("Error opening or reading file: {}".format(filepath))
 
     return Base64(Join(delimiter, data))
+
+
+def from_file_sub(filepath):
+    """Imports userdata from a file, using Sub for replacing inline variables such as ${AWS::Region}
+
+    :type filepath: string
+
+    :param filepath  The absolute path to the file.
+
+    rtype: troposphere.Base64
+    :return The base64 representation of the file.
+    """
+
+    try:
+        with open(filepath, "rt") as f:
+            data = f.read()
+            return Base64(Sub(data))
+    except IOError:
+        raise IOError("Error opening or reading file: {}".format(filepath))
diff --git a/troposphere/rds.py b/troposphere/rds.py
index e034c5ff..41b7f336 100644
--- a/troposphere/rds.py
+++ b/troposphere/rds.py
@@ -582,6 +582,7 @@ class DBCluster(AWSObject):
         "Engine": (validate_engine, True),
         "EngineMode": (validate_engine_mode, False),
         "EngineVersion": (str, False),
+        "GlobalClusterIdentifier": (str, False),
         "KmsKeyId": (str, False),
         "MasterUsername": (str, False),
         "MasterUserPassword": (str, False),
diff --git a/troposphere/validators.py b/troposphere/validators.py
index a9365a73..f47f4080 100644
--- a/troposphere/validators.py
+++ b/troposphere/validators.py
@@ -658,3 +658,21 @@ def ecs_efs_encryption_status(status):
             % (", ".join(valid_status))
         )
     return status
+
+
+def wafv2_custom_body_response_content(content):
+    """validate wafv2 custom body response content. Any character between 1 to 10240"""
+    if not content:
+        raise ValueError("Content must not be empty")
+    if len(content) > 10240:
+        raise ValueError("Content maximum length must not exceed 10240")
+
+    return content
+
+
+def wafv2_custom_body_response_content_type(content_type):
+    """validate wafv2 custom response content type"""
+    valid_types = ["APPLICATION_JSON", "TEXT_HTML", "TEXT_PLAIN"]
+    if content_type not in valid_types:
+        raise ValueError('ContentType must be one of: "%s"' % (", ".join(valid_types)))
+    return content_type
diff --git a/troposphere/wafv2.py b/troposphere/wafv2.py
index bd612e37..50a40bc1 100644
--- a/troposphere/wafv2.py
+++ b/troposphere/wafv2.py
@@ -4,7 +4,12 @@
 # See LICENSE file for full license.
 
 from . import AWSObject, AWSProperty, Tags
-from .validators import boolean, integer
+from .validators import (
+    boolean,
+    integer,
+    wafv2_custom_body_response_content,
+    wafv2_custom_body_response_content_type,
+)
 
 VALID_TRANSFORMATION_TYPES = (
     "CMD_LINE",
@@ -68,6 +73,18 @@ def validate_positional_constraint(positional_constraint):
     return positional_constraint
 
 
+def validate_custom_response_bodies(custom_response_bodies):
+    """validate custom response bodies"""
+    if not isinstance(custom_response_bodies, dict):
+        raise ValueError("CustomResponseBodies must be dict")
+
+    for k, v in custom_response_bodies.items():
+        if not isinstance(v, CustomResponseBody):
+            raise ValueError("value of %s must be type of CustomResponseBody" % (k))
+
+    return custom_response_bodies
+
+
 class ExcludedRule(AWSProperty):
     props = {"Name": (str, False)}
 
@@ -354,6 +371,7 @@ class WebACL(AWSObject):
     resource_type = "AWS::WAFv2::WebACL"
 
     props = {
+        "CustomResponseBodies": (validate_custom_response_bodies, False),
         "DefaultAction": (DefaultAction, False),
         "Description": (str, False),
         "Name": (str, False),
@@ -404,6 +422,7 @@ class RuleGroup(AWSObject):
 
     props = {
         "Capacity": (integer, False),
+        "CustomResponseBodies": (validate_custom_response_bodies, False),
         "Description": (str, False),
         "Name": (str, False),
         "Rules": ([RuleGroupRule], False),
@@ -420,3 +439,10 @@ class WebACLAssociation(AWSObject):
         "ResourceArn": (str, True),
         "WebACLArn": (str, True),
     }
+
+
+class CustomResponseBody(AWSObject):
+    props = {
+        "Content": (wafv2_custom_body_response_content, True),
+        "ContentType": (wafv2_custom_body_response_content_type, True),
+    }


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 3e8749d8..3d9e38e1 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -27,6 +27,8 @@ from troposphere.validators import (
     task_type,
     tg_healthcheck_port,
     waf_action_type,
+    wafv2_custom_body_response_content,
+    wafv2_custom_body_response_content_type,
 )
 
 
@@ -273,6 +275,24 @@ class TestValidators(unittest.TestCase):
             with self.assertRaises(ValueError):
                 waf_action_type(s)
 
+    def test_wafv2_custom_body_response_content(self):
+        for s in [
+            "{'hello': 'world'}",
+            "<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Test</h1><p>Test.</p></body></html>",
+            "Health",
+        ]:
+            wafv2_custom_body_response_content(s)
+        for s in ["", "a" * 10241]:
+            with self.assertRaises(ValueError):
+                wafv2_custom_body_response_content(s)
+
+    def test_wafv2_custom_body_response_content_type(self):
+        for s in ["APPLICATION_JSON", "TEXT_HTML", "TEXT_PLAIN"]:
+            wafv2_custom_body_response_content_type(s)
+        for s in ["", "APPLICATION", "HTML", "TEXT"]:
+            with self.assertRaises(ValueError):
+                wafv2_custom_body_response_content_type(s)
+
 
 if __name__ == "__main__":
     unittest.main()