CloudFormation: Allow Conditions in Outputs (#7470)

This commit is contained in:
Bert Blommers 2024-03-14 23:26:15 +00:00 committed by GitHub
parent 18a392bd0a
commit e4ac65f7cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 266 additions and 5 deletions

View File

@ -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

View File

@ -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):

View File

@ -812,6 +812,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
<NotificationARNs/>
{% endif %}
<DisableRollback>false</DisableRollback>
{%if stack.stack_outputs %}
<Outputs>
{% for output in stack.stack_outputs %}
<member>
@ -820,6 +821,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
</member>
{% endfor %}
</Outputs>
{% endif %}
<Parameters>
{% for param_name, param_value in stack.stack_parameters.items() %}
<member>

View File

@ -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

View File

@ -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}"

View File

@ -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")