Repository: cloudtools/troposphere
Pull request #1908 (2021-06-01 13:17:35)

AWS::RDS::DBCluster: add missing GlobalClusterIdentifier parameter
`GlobalClusterIdentifier` is missing from `troposphere.rds.DBCluster`.

From CloudFormation Resource Specification:

```json
        "GlobalClusterIdentifier": {
          "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html#cfn-rds-dbcluster-globalclusteridentifier",
          "PrimitiveType": "String",
          "Required": false,
          "UpdateType": "Conditional"
        },
```

From the [documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html#cfn-rds-dbcluster-globalclusteridentifier):

> If you are configuring an Aurora global database cluster and want your Aurora DB cluster to be a secondary member in the global database cluster, specify the global cluster ID of the global database cluster. To define the primary database cluster of the global cluster, use the AWS::RDS::GlobalCluster resource.


Patch:
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index c959bde4..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-language: python
-dist: xenial
-python:
-  - "3.6"
-  - "3.7.3"
-  - "3.8"
-  - "3.9"
-
-before_install:
-  - pip install --upgrade pip setuptools wheel
-
-install:
-  - pip install --upgrade flake8 cfn_flip>=1.0.2
-
-before_script:
-  - flake8 --version
-  - flake8 . --show-source 
-
-script:
-  - python setup.py test
diff --git a/Makefile b/Makefile
index 972065e3..e0a1263a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,22 +2,48 @@
 
 PYDIRS=setup.py examples scripts tests troposphere
 
-test:
-	flake8 ${PYDIRS}
-	python setup.py test
-	black --check ${PYDIRS}
-	isort --check ${PYDIRS}
+help: ## show this message
+	@IFS=$$'\n' ; \
+	help_lines=(`fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##/:/'`); \
+	printf "%-30s %s\n" "target" "help" ; \
+	printf "%-30s %s\n" "------" "----" ; \
+	for help_line in $${help_lines[@]}; do \
+		IFS=$$':' ; \
+		help_split=($$help_line) ; \
+		help_command=`echo $${help_split[0]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \
+		help_info=`echo $${help_split[2]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \
+		printf '\033[36m'; \
+		printf "%-30s %s" $$help_command ; \
+		printf '\033[0m'; \
+		printf "%s\n" $$help_info; \
+	done
 
-spec:
-	curl -O https://d1uauaxba7bl26.cloudfront.net/latest/CloudFormationResourceSpecification.zip
-	rm -rf spec
-	mkdir spec
-	unzip -d spec CloudFormationResourceSpecification.zip
-	rm CloudFormationResourceSpecification.zip
+fix-black: ## automatically fix all black errors
+	@black ${PYDIRS}
 
-spec2:
-	curl -O --compressed https://d1uauaxba7bl26.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json
-	/bin/echo -n "Downloaded version: " && jq .ResourceSpecificationVersion CloudFormationResourceSpecification.json
+fix-isort: ## automatically fix all isort errors
+	@isort ${PYDIRS}
+
+clean:
+	rm -rf ${p39dir} troposphere.egg-info
+
+lint: lint-flake8 ## run all linters
+
+lint-black: ## run black
+	@echo "Running black... If this fails, run 'make fix-black' to resolve."
+	@black ${PYDIRS} --check --color --diff
+	@echo ""
+
+lint-flake8: ## run flake8
+	@echo "Running flake8..."
+	@flake8 --version
+	@flake8 --config=setup.cfg --show-source
+	@echo ""
+
+lint-isort: ## run isort
+	@echo "Running isort... If this fails, run 'make fix-isort' to resolve."
+	@isort ${PYDIRS} --check-only
+	@echo ""
 
 release-test:
 	python setup.py sdist
@@ -34,5 +60,16 @@ release-test-39:
 	deactivate && \
 	rm -rf ${p39dir}
 
-clean:
-	rm -rf ${p39dir} troposphere.egg-info
+spec:
+	curl -O https://d1uauaxba7bl26.cloudfront.net/latest/CloudFormationResourceSpecification.zip
+	rm -rf spec
+	mkdir spec
+	unzip -d spec CloudFormationResourceSpecification.zip
+	rm CloudFormationResourceSpecification.zip
+
+spec2:
+	curl -O --compressed https://d1uauaxba7bl26.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json
+	/bin/echo -n "Downloaded version: " && jq .ResourceSpecificationVersion CloudFormationResourceSpecification.json
+
+test: ## run tests
+	@python setup.py test
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/constants.py b/troposphere/constants.py
index 76cb332d..d4b587cd 100644
--- a/troposphere/constants.py
+++ b/troposphere/constants.py
@@ -724,6 +724,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 +744,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 +770,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/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..24b795ec
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,44 @@
+name: Tests
+
+
+on:
+  pull_request:
+  push:
+    branches:
+      - master
+
+
+defaults:
+  run:
+    shell: bash
+
+
+jobs:
+  test-python:
+    name: Python Linting & Tests
+    strategy:
+      matrix:
+        python-version: [3.6, 3.7, 3.8, 3.9]
+        os: [ubuntu-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Pip Cache (Linux)
+        uses: actions/cache@v1
+        if: runner.os == 'Linux'
+        with:
+          path: ~/.cache/pip
+          key: ${{ runner.os }}-pip-${{ matrix.python-version }}
+          restore-keys: |
+            ${{ runner.os }}-pip-${{ matrix.python-version }}
+      - name: Install Python Dependencies
+        run: |
+          python -m pip install --upgrade pip setuptools wheel
+          python -m pip install --upgrade flake8 cfn_flip>=1.0.2
+      - name: Run Lints
+        run: make lint
+      - name: Run Tests
+        run: make test
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()