From e4ac65f7cba385d2389019d7caf756cbbe36ee9e Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Thu, 14 Mar 2024 23:26:15 +0000 Subject: [PATCH] CloudFormation: Allow Conditions in Outputs (#7470) --- .github/workflows/tests_real_aws.yml | 2 +- moto/cloudformation/parsing.py | 22 +- moto/cloudformation/responses.py | 2 + tests/test_cloudformation/__init__.py | 31 +++ .../test_cloudformation_custom_resources.py | 2 +- tests/test_cloudformation/test_conditions.py | 212 ++++++++++++++++++ 6 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 tests/test_cloudformation/test_conditions.py diff --git a/.github/workflows/tests_real_aws.yml b/.github/workflows/tests_real_aws.yml index 350464194..71c401ad4 100644 --- a/.github/workflows/tests_real_aws.yml +++ b/.github/workflows/tests_real_aws.yml @@ -42,4 +42,4 @@ jobs: env: MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }} run: | - pytest -sv tests/test_dynamodb/ tests/test_ec2/ tests/test_iam/ tests/test_lakeformation/ tests/test_logs/ tests/test_sqs/ tests/test_ses/ tests/test_s3* tests/test_sns/ -m aws_verified + pytest -sv tests/test_cloudformation/ tests/test_dynamodb/ tests/test_ec2/ tests/test_iam/ tests/test_lakeformation/ tests/test_logs/ tests/test_sqs/ tests/test_ses/ tests/test_s3* tests/test_sns/ -m aws_verified diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py index 5a1216be7..0a6b913b6 100644 --- a/moto/cloudformation/parsing.py +++ b/moto/cloudformation/parsing.py @@ -475,6 +475,11 @@ def parse_condition(condition: Union[Dict[str, Any], bool], resources_map: "Reso def parse_output( output_logical_id: str, output_json: Any, resources_map: "ResourceMap" ) -> Optional[Output]: + if "Condition" in output_json and not resources_map.lazy_condition_map.get( + output_json["Condition"] + ): + # This Resource is not initialized - impossible to show Output + return None output_json = clean_json(output_json, resources_map) if "Value" not in output_json: return None @@ -684,11 +689,22 @@ class ResourceMap(collections_abc.Mapping): # type: ignore[type-arg] def validate_outputs(self) -> None: outputs = self._template.get("Outputs") or {} for value in outputs.values(): + if "Condition" in value: + if not self.lazy_condition_map[value["Condition"]]: + # This Output is not shown - no point in validating it + continue value = value.get("Value", {}) if "Fn::GetAtt" in value: - resource_type = self._resource_json_map.get(value["Fn::GetAtt"][0])[ # type: ignore[index] - "Type" - ] + resource_name = value["Fn::GetAtt"][0] + resource = self._resource_json_map.get(resource_name) + # validate resource will be created + if "Condition" in resource: # type: ignore + if not self.lazy_condition_map[resource["Condition"]]: # type: ignore[index] + raise ValidationError( + message=f"Unresolved resource dependencies [{resource_name}] in the Outputs block of the template" + ) + # Validate attribute exists on this Type + resource_type = resource["Type"] # type: ignore[index] attr = value["Fn::GetAtt"][1] resource_class = resource_class_from_type(resource_type) if not resource_class.has_cfn_attr(attr): diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py index 07a1fb363..b848f5e14 100644 --- a/moto/cloudformation/responses.py +++ b/moto/cloudformation/responses.py @@ -812,6 +812,7 @@ DESCRIBE_STACKS_TEMPLATE = """ {% endif %} false + {%if stack.stack_outputs %} {% for output in stack.stack_outputs %} @@ -820,6 +821,7 @@ DESCRIBE_STACKS_TEMPLATE = """ {% endfor %} + {% endif %} {% for param_name, param_value in stack.stack_parameters.items() %} diff --git a/tests/test_cloudformation/__init__.py b/tests/test_cloudformation/__init__.py index e69de29bb..2dc371035 100644 --- a/tests/test_cloudformation/__init__.py +++ b/tests/test_cloudformation/__init__.py @@ -0,0 +1,31 @@ +import os +from functools import wraps + +from moto import mock_aws + + +def cloudformation_aws_verified(): + """ + Function that is verified to work against AWS. + Can be run against AWS at any time by setting: + MOTO_TEST_ALLOW_AWS_REQUEST=true + + If this environment variable is not set, the function runs in a `mock_aws` context. + """ + + def inner(func): + @wraps(func) + def pagination_wrapper(): + allow_aws_request = ( + os.environ.get("MOTO_TEST_ALLOW_AWS_REQUEST", "false").lower() == "true" + ) + + if allow_aws_request: + return func() + else: + with mock_aws(): + return func() + + return pagination_wrapper + + return inner diff --git a/tests/test_cloudformation/test_cloudformation_custom_resources.py b/tests/test_cloudformation/test_cloudformation_custom_resources.py index 97e1d8e9b..b5da00d38 100644 --- a/tests/test_cloudformation/test_cloudformation_custom_resources.py +++ b/tests/test_cloudformation/test_cloudformation_custom_resources.py @@ -150,7 +150,7 @@ def test_create_custom_lambda_resource__verify_manual_request(): ) stack_id = stack["StackId"] stack = cf.describe_stacks(StackName=stack_id)["Stacks"][0] - assert stack["Outputs"] == [] + assert "Outputs" not in stack assert stack["StackStatus"] == "CREATE_IN_PROGRESS" callback_url = f"http://cloudformation.{region_name}.amazonaws.com/cloudformation_{region_name}/cfnresponse?stack={stack_id}" diff --git a/tests/test_cloudformation/test_conditions.py b/tests/test_cloudformation/test_conditions.py new file mode 100644 index 000000000..343d3403c --- /dev/null +++ b/tests/test_cloudformation/test_conditions.py @@ -0,0 +1,212 @@ +from uuid import uuid4 + +import boto3 +import pytest +from botocore.exceptions import ClientError + +from . import cloudformation_aws_verified + +conditional_output_conditional_value = """ +Outputs: + roleArn: + Condition: RoleCondition + Value: !GetAtt role.Arn + +Parameters: + roleName: + Type: String + Description: Predefined role name + +Conditions: + RoleCondition: !Equals [ !Ref roleName, "myrole" ] + +Resources: + role: + Condition: RoleCondition + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - 'sts:AssumeRole' +""" + + +@pytest.mark.aws_verified +@cloudformation_aws_verified() +def test_conditional_output_conditional_value(): + cf = boto3.client("cloudformation", region_name="us-east-1") + param = [{"ParameterKey": "roleName", "ParameterValue": "myrole"}] + name = f"stack{str(uuid4())[0:6]}" + cf.create_stack( + StackName=name, + TemplateBody=conditional_output_conditional_value, + Parameters=param, + Capabilities=["CAPABILITY_IAM"], + ) + waiter = cf.get_waiter("stack_create_complete") + waiter.wait(StackName=name) + + stack = cf.describe_stacks(StackName=name)["Stacks"][0] + assert stack["Outputs"][0]["OutputKey"] == "roleArn" + + cf.delete_stack(StackName=name) + + # verify role does not exist with different name + param = [{"ParameterKey": "roleName", "ParameterValue": "diffname"}] + cf.create_stack( + StackName=f"{name}2", + TemplateBody=conditional_output_conditional_value, + Parameters=param, + Capabilities=["CAPABILITY_IAM"], + ) + waiter = cf.get_waiter("stack_create_complete") + waiter.wait(StackName=f"{name}2") + stack = cf.describe_stacks()["Stacks"][0] + assert "Outputs" not in stack + + cf.delete_stack(StackName=f"{name}2") + + +permanent_output_conditional_value = """ +Outputs: + roleArn: + Value: !GetAtt role.Arn + +Parameters: + roleName: + Type: String + Description: Predefined role name + +Conditions: + RoleCondition: !Equals [ !Ref roleName, "myrole" ] + +Resources: + role: + Condition: RoleCondition + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - 'sts:AssumeRole' +""" + + +@pytest.mark.aws_verified +@cloudformation_aws_verified() +def test_permanent_output_conditional_value(): + cf = boto3.client("cloudformation", region_name="us-east-1") + param = [{"ParameterKey": "roleName", "ParameterValue": "myrole"}] + + name = f"stack{str(uuid4())[0:6]}" + cf.create_stack( + StackName=name, + TemplateBody=permanent_output_conditional_value, + Parameters=param, + Capabilities=["CAPABILITY_IAM"], + ) + waiter = cf.get_waiter("stack_create_complete") + waiter.wait(StackName=name) + + # Works - because role exists + stack = cf.describe_stacks(StackName=name)["Stacks"][0] + assert stack["Outputs"][0]["OutputKey"] == "roleArn" + + cf.delete_stack(StackName=name) + + # Role does not exist with different name + # So we can't get the Output either + param = [{"ParameterKey": "roleName", "ParameterValue": "diffname"}] + with pytest.raises(ClientError) as exc: + cf.create_stack( + StackName=f"{name}2", + TemplateBody=permanent_output_conditional_value, + Parameters=param, + Capabilities=["CAPABILITY_IAM"], + ) + err = exc.value.response["Error"] + assert err["Code"] == "ValidationError" + assert ( + err["Message"] + == "Unresolved resource dependencies [role] in the Outputs block of the template" + ) + + +conditional_output_of_permanent_value = """ +Outputs: + roleArn: + Condition: RoleCondition + Value: !GetAtt role.Arn + +Parameters: + roleName: + Type: String + Description: Predefined role name + +Conditions: + RoleCondition: !Equals [ !Ref roleName, "myrole" ] + +Resources: + role: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - 'sts:AssumeRole' +""" + + +@pytest.mark.aws_verified +@cloudformation_aws_verified() +def test_conditional_output_of_permanent_value(): + cf = boto3.client("cloudformation", region_name="us-east-1") + param = [{"ParameterKey": "roleName", "ParameterValue": "myrole"}] + + name = f"stack{str(uuid4())[0:6]}" + cf.create_stack( + StackName=name, + TemplateBody=conditional_output_of_permanent_value, + Parameters=param, + Capabilities=["CAPABILITY_IAM"], + ) + waiter = cf.get_waiter("stack_create_complete") + waiter.wait(StackName=name) + + stack = cf.describe_stacks(StackName=name)["Stacks"][0] + assert stack["Outputs"][0]["OutputKey"] == "roleArn" + + cf.delete_stack(StackName=name) + + # verify role does not exist with different name + param = [{"ParameterKey": "roleName", "ParameterValue": "diffname"}] + cf.create_stack( + StackName=f"{name}2", + TemplateBody=conditional_output_of_permanent_value, + Parameters=param, + Capabilities=["CAPABILITY_IAM"], + ) + waiter = cf.get_waiter("stack_create_complete") + waiter.wait(StackName=f"{name}2") + stack = cf.describe_stacks(StackName=f"{name}2")["Stacks"][0] + # The Role does exist + # But Output is conditional, and just doesn't return anything + assert "Outputs" not in stack + + cf.delete_stack(StackName=f"{name}2")