CloudFormation: Allow Conditions in Outputs (#7470)
This commit is contained in:
parent
18a392bd0a
commit
e4ac65f7cb
2
.github/workflows/tests_real_aws.yml
vendored
2
.github/workflows/tests_real_aws.yml
vendored
@ -42,4 +42,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }}
|
MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }}
|
||||||
run: |
|
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
|
||||||
|
@ -475,6 +475,11 @@ def parse_condition(condition: Union[Dict[str, Any], bool], resources_map: "Reso
|
|||||||
def parse_output(
|
def parse_output(
|
||||||
output_logical_id: str, output_json: Any, resources_map: "ResourceMap"
|
output_logical_id: str, output_json: Any, resources_map: "ResourceMap"
|
||||||
) -> Optional[Output]:
|
) -> 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)
|
output_json = clean_json(output_json, resources_map)
|
||||||
if "Value" not in output_json:
|
if "Value" not in output_json:
|
||||||
return None
|
return None
|
||||||
@ -684,11 +689,22 @@ class ResourceMap(collections_abc.Mapping): # type: ignore[type-arg]
|
|||||||
def validate_outputs(self) -> None:
|
def validate_outputs(self) -> None:
|
||||||
outputs = self._template.get("Outputs") or {}
|
outputs = self._template.get("Outputs") or {}
|
||||||
for value in outputs.values():
|
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", {})
|
value = value.get("Value", {})
|
||||||
if "Fn::GetAtt" in value:
|
if "Fn::GetAtt" in value:
|
||||||
resource_type = self._resource_json_map.get(value["Fn::GetAtt"][0])[ # type: ignore[index]
|
resource_name = value["Fn::GetAtt"][0]
|
||||||
"Type"
|
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]
|
attr = value["Fn::GetAtt"][1]
|
||||||
resource_class = resource_class_from_type(resource_type)
|
resource_class = resource_class_from_type(resource_type)
|
||||||
if not resource_class.has_cfn_attr(attr):
|
if not resource_class.has_cfn_attr(attr):
|
||||||
|
@ -812,6 +812,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
|
|||||||
<NotificationARNs/>
|
<NotificationARNs/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<DisableRollback>false</DisableRollback>
|
<DisableRollback>false</DisableRollback>
|
||||||
|
{%if stack.stack_outputs %}
|
||||||
<Outputs>
|
<Outputs>
|
||||||
{% for output in stack.stack_outputs %}
|
{% for output in stack.stack_outputs %}
|
||||||
<member>
|
<member>
|
||||||
@ -820,6 +821,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
|
|||||||
</member>
|
</member>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</Outputs>
|
</Outputs>
|
||||||
|
{% endif %}
|
||||||
<Parameters>
|
<Parameters>
|
||||||
{% for param_name, param_value in stack.stack_parameters.items() %}
|
{% for param_name, param_value in stack.stack_parameters.items() %}
|
||||||
<member>
|
<member>
|
||||||
|
@ -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
|
@ -150,7 +150,7 @@ def test_create_custom_lambda_resource__verify_manual_request():
|
|||||||
)
|
)
|
||||||
stack_id = stack["StackId"]
|
stack_id = stack["StackId"]
|
||||||
stack = cf.describe_stacks(StackName=stack_id)["Stacks"][0]
|
stack = cf.describe_stacks(StackName=stack_id)["Stacks"][0]
|
||||||
assert stack["Outputs"] == []
|
assert "Outputs" not in stack
|
||||||
assert stack["StackStatus"] == "CREATE_IN_PROGRESS"
|
assert stack["StackStatus"] == "CREATE_IN_PROGRESS"
|
||||||
|
|
||||||
callback_url = f"http://cloudformation.{region_name}.amazonaws.com/cloudformation_{region_name}/cfnresponse?stack={stack_id}"
|
callback_url = f"http://cloudformation.{region_name}.amazonaws.com/cloudformation_{region_name}/cfnresponse?stack={stack_id}"
|
||||||
|
212
tests/test_cloudformation/test_conditions.py
Normal file
212
tests/test_cloudformation/test_conditions.py
Normal 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")
|
Loading…
x
Reference in New Issue
Block a user