Repository: aws-quickstart/taskcat (python)

Issue:
Feature Request: Optional Stack Role ARN Parameter
Something that would be extremely helpful was if taskcat supported passing a stack role to be used when creating test stacks. 

When deploying CloudFormation stacks in a CodePipeline, the stack role is a required parameter. This can lead to some interesting issues where taskcat can pass its tests, but the deployment can fail due to subtle differences in the roles between the two. Additionally, it would be nice to limit the permissions given to the CodeBuild project running taskcat, but allow it to pass a role to CloudFormation for the test stacks.

Pull request:
Adding role_arn to Project,Test config schema, plus tests. Closes #323
## Overview

This adds `role_arn` to project and test-level config schema. I've added tests, but local env issues are preventing a full test suite. Need to iron that out before ready for review. 

Patch:
diff --git a/.gitallowed b/.gitallowed
index df49756..8245417 100644
--- a/.gitallowed
+++ b/.gitallowed
@@ -1,1 +1,2 @@
 123412341234
+123456789012
diff --git a/taskcat/_cfn/stack.py b/taskcat/_cfn/stack.py
index 11005b7..4c0293e 100644
--- a/taskcat/_cfn/stack.py
+++ b/taskcat/_cfn/stack.py
@@ -206,6 +206,7 @@ class Stack:  # pylint: disable=too-many-instance-attributes
         self.region_name = region.name
         self.client: boto3.client = region.client("cloudformation")
         self.completion_time: timedelta = timedelta(0)
+        self.role_arn = region.role_arn
 
         # properties from additional cfn api calls
         self._events: Events = Events()
@@ -276,14 +277,17 @@ class Stack:  # pylint: disable=too-many-instance-attributes
             ),
             template_cache=tcat_template_cache,
         )
-        stack_id = cfn_client.create_stack(
-            StackName=stack_name,
-            TemplateURL=template.url,
-            Parameters=parameters,
-            DisableRollback=disable_rollback,
-            Tags=tags,
-            Capabilities=Capabilities.ALL,
-        )["StackId"]
+        create_options = {
+            "StackName": stack_name,
+            "TemplateURL": template.url,
+            "Parameters": parameters,
+            "DisableRollback": disable_rollback,
+            "Tags": tags,
+            "Capabilities": Capabilities.ALL,
+        }
+        if region.role_arn:
+            create_options["RoleARN"] = region.role_arn
+        stack_id = cfn_client.create_stack(**create_options)["StackId"]
         stack = cls(region, stack_id, template, test_name, uuid)
         # fetch property values from cfn
         stack.refresh()
diff --git a/taskcat/_cfn/threaded.py b/taskcat/_cfn/threaded.py
index 4df6c01..2198e3d 100644
--- a/taskcat/_cfn/threaded.py
+++ b/taskcat/_cfn/threaded.py
@@ -222,7 +222,7 @@ class Stacker:
                         project = v
                 if match and test and project:
                     stack = Stack.import_existing(
-                        stack_props, tests[test].template, region[0], test, uid
+                        stack_props, tests[test].template, region[0], test, uid,
                     )
                     stacks.append(stack)
         return stacks
diff --git a/taskcat/_config.py b/taskcat/_config.py
index fd945a3..8f7fcb9 100644
--- a/taskcat/_config.py
+++ b/taskcat/_config.py
@@ -240,6 +240,7 @@ class Config:
                     profile=profile,
                     _boto3_cache=boto3_cache,
                     taskcat_id=self.uid,
+                    _role_name=test.role_name,
                 )
         return region_objects
 
@@ -401,6 +402,7 @@ class Config:
                         parameters[test_name][region_obj.name],
                     )
                 )
+
             tests[test_name] = TestObj(
                 name=test_name,
                 template_path=self.project_root / test.template,
diff --git a/taskcat/_dataclasses.py b/taskcat/_dataclasses.py
index 43ae93b..1fa86af 100644
--- a/taskcat/_dataclasses.py
+++ b/taskcat/_dataclasses.py
@@ -71,6 +71,7 @@ METADATA = {
     "shorten_stack_name": {
         "description": "Shorten stack names generated for tests, set to true to enable"
     },
+    "role_name": {"description": "Role name to use when launching CFN Stacks."},
 }
 
 # types
@@ -87,7 +88,6 @@ S3BucketName = NewType("S3BucketName", AlNumDash)
 TestName = NewType("TestName", AlNumDash)
 AzId = NewType("AzId", str)
 Templates = NewType("Templates", Dict[TestName, Template])
-
 # regex validation
 
 
@@ -168,9 +168,6 @@ class AzIdField(FieldEncoder):
 JsonSchemaMixin.register_field_encoders({AzId: AzIdField()})
 
 
-#
-
-
 # dataclasses
 @dataclass
 class RegionObj:
@@ -180,6 +177,7 @@ class RegionObj:
     profile: str
     taskcat_id: UUID
     _boto3_cache: Boto3Cache
+    _role_name: Optional[str]
 
     def client(self, service: str):
         return self._boto3_cache.client(service, region=self.name, profile=self.profile)
@@ -188,6 +186,12 @@ class RegionObj:
     def session(self):
         return self._boto3_cache.session(region=self.name, profile=self.profile)
 
+    @property
+    def role_arn(self):
+        if self._role_name:
+            return f"arn:{self.partition}:iam::{self.account_id}:role/{self._role_name}"
+        return None
+
 
 @dataclass
 class S3BucketObj:
@@ -392,6 +396,7 @@ class TestConfig(JsonSchemaMixin, allow_additional_props=False):  # type: ignore
     az_blacklist: Optional[List[AzId]] = field(
         default=None, metadata=METADATA["az_ids"]
     )
+    role_name: Optional[str] = field(default=None, metadata=METADATA["role_name"])
 
 
 # pylint: disable=too-many-instance-attributes
@@ -442,6 +447,7 @@ class ProjectConfig(JsonSchemaMixin, allow_additional_props=False):  # type: ign
     shorten_stack_name: Optional[bool] = field(
         default=None, metadata=METADATA["shorten_stack_name"]
     )
+    role_name: Optional[str] = field(default=None, metadata=METADATA["role_name"])
 
 
 PROPAGATE_KEYS = ["tags", "parameters", "auth"]
diff --git a/taskcat/cfg/config_schema.json b/taskcat/cfg/config_schema.json
index 497ebbb..ac044c0 100644
--- a/taskcat/cfg/config_schema.json
+++ b/taskcat/cfg/config_schema.json
@@ -145,6 +145,10 @@
                     },
                     "type": "array"
                 },
+                "role_name": {
+                    "description": "Role name to use when launching CFN Stacks.",
+                    "type": "string"
+                },
                 "s3_bucket": {
                     "description": "Name of S3 bucket to upload project to, if left out a bucket will be auto-generated",
                     "pattern": "^[a-z0-9-]*$",
@@ -241,6 +245,10 @@
                     },
                     "type": "array"
                 },
+                "role_name": {
+                    "description": "Role name to use when launching CFN Stacks.",
+                    "type": "string"
+                },
                 "s3_bucket": {
                     "description": "Name of S3 bucket to upload project to, if left out a bucket will be auto-generated",
                     "pattern": "^[a-z0-9-]*$",
@@ -290,6 +298,7 @@
                 "package_lambda": null,
                 "parameters": null,
                 "regions": null,
+                "role_name": null,
                 "s3_bucket": null,
                 "s3_enable_sig_v2": null,
                 "s3_object_acl": null,


Test patch:
diff --git a/tests/test_cfn_stack.py b/tests/test_cfn_stack.py
index 5a8aa4a..3d918f0 100644
--- a/tests/test_cfn_stack.py
+++ b/tests/test_cfn_stack.py
@@ -225,12 +225,12 @@ def mock_client_method(*args, **kwargs):
 
 @mock.patch("taskcat._client_factory.Boto3Cache", autospec=True)
 @mock.patch("taskcat._dataclasses.S3BucketObj")
-def make_test_region_obj(name, m_s3, m_boto):
+def make_test_region_obj(name, m_s3, m_boto, role_name=""):
     boto_cache = m_boto()
     s3 = m_s3(
         name="test_bucket",
         region="us-east-1",
-        account_id="123412341234",
+        account_id="123456789012",
         partition="aws",
         s3_client=boto_cache.client("s3"),
         sigv4=True,
@@ -240,13 +240,14 @@ def make_test_region_obj(name, m_s3, m_boto):
     )
     region = TestRegion(
         name=name,
-        account_id="123412341234",
+        account_id="123456789012",
         partition="aws",
         profile="default",
         taskcat_id=uuid.UUID(int=1),
         _boto3_cache=boto_cache,
         s3_bucket=s3,
         parameters={},
+        _role_name=role_name,
     )
     region.s3_bucket = s3
     return region
@@ -280,6 +281,39 @@ class TestStack(unittest.TestCase):
         self.assertNotEquals(template, stack.template)
         mock_template.assert_called_once()
 
+    @mock.patch(
+        "taskcat._cfn.stack.s3_url_maker",
+        return_value="https://test.s3.amazonaws.com/prefix/object",
+    )
+    @mock.patch("taskcat._cfn.stack.Template", return_value=make_test_template())
+    def test_create_with_role_arn(self, mock_template, m_s3_url_maker):
+        region = make_test_region_obj("us-west-2", role_name="ExampleRole")
+        mock_cfn_client = mock_client_method("cloudformation")
+        region.client = mock_cfn_client
+        region.client.return_value = mock_cfn_client
+        template = make_test_template()
+        stack = Stack.create(region=region, stack_name="stack_name", template=template)
+        self.assertIsInstance(stack._timer, Timer)
+        # disabled due to a concurrency issue with the tests
+        # self.assertEqual(stack._timer.is_alive(), True)
+        stack._timer.cancel()
+        m_s3_url_maker.assert_called_once()
+        self.assertNotEquals(template, stack.template)
+        mock_template.assert_called_once()
+        region.client.create_stack.assert_called_with(
+            Capabilities=[
+                "CAPABILITY_IAM",
+                "CAPABILITY_NAMED_IAM",
+                "CAPABILITY_AUTO_EXPAND",
+            ],
+            DisableRollback=True,
+            Parameters=[],
+            RoleARN="arn:aws:iam::123456789012:role/ExampleRole",
+            StackName="stack_name",
+            Tags=[],
+            TemplateURL="blah",
+        )
+
     @mock.patch(
         "taskcat._cfn.stack.s3_url_maker",
         return_value="https://test.s3.amazonaws.com/prefix/object",