diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py index 426b44410f7c7..8995624c50d7c 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py @@ -117,6 +117,7 @@ class NodeTemplate(ChangeSetNode): parameters: Final[NodeParameters] conditions: Final[NodeConditions] resources: Final[NodeResources] + outputs: Final[NodeOutputs] def __init__( self, @@ -126,12 +127,14 @@ def __init__( parameters: NodeParameters, conditions: NodeConditions, resources: NodeResources, + outputs: NodeOutputs, ): super().__init__(scope=scope, change_type=change_type) self.mappings = mappings self.parameters = parameters self.conditions = conditions self.resources = resources + self.outputs = outputs class NodeDivergence(ChangeSetNode): @@ -189,6 +192,36 @@ def __init__(self, scope: Scope, change_type: ChangeType, mappings: list[NodeMap self.mappings = mappings +class NodeOutput(ChangeSetNode): + name: Final[str] + value: Final[ChangeSetEntity] + export: Final[Optional[ChangeSetEntity]] + condition_reference: Final[Optional[TerminalValue]] + + def __init__( + self, + scope: Scope, + change_type: ChangeType, + name: str, + value: ChangeSetEntity, + export: Optional[ChangeSetEntity], + conditional_reference: Optional[TerminalValue], + ): + super().__init__(scope=scope, change_type=change_type) + self.name = name + self.value = value + self.export = export + self.condition_reference = conditional_reference + + +class NodeOutputs(ChangeSetNode): + outputs: Final[list[NodeOutput]] + + def __init__(self, scope: Scope, change_type: ChangeType, outputs: list[NodeOutput]): + super().__init__(scope=scope, change_type=change_type) + self.outputs = outputs + + class NodeCondition(ChangeSetNode): name: Final[str] body: Final[ChangeSetEntity] @@ -218,7 +251,7 @@ def __init__(self, scope: Scope, change_type: ChangeType, resources: list[NodeRe class NodeResource(ChangeSetNode): name: Final[str] type_: Final[ChangeSetTerminal] - condition_reference: Final[TerminalValue] + condition_reference: Final[Optional[TerminalValue]] properties: Final[NodeProperties] def __init__( @@ -325,6 +358,9 @@ def __init__(self, scope: Scope, value: Any): ResourcesKey: Final[str] = "Resources" PropertiesKey: Final[str] = "Properties" ParametersKey: Final[str] = "Parameters" +ValueKey: Final[str] = "Value" +ExportKey: Final[str] = "Export" +OutputsKey: Final[str] = "Outputs" # TODO: expand intrinsic functions set. RefKey: Final[str] = "Ref" FnIf: Final[str] = "Fn::If" @@ -567,17 +603,9 @@ def _visit_object( binding_scope, (before_value, after_value) = self._safe_access_in( scope, binding_name, before_object, after_object ) - if self._is_intrinsic_function_name(function_name=binding_name): - value = self._visit_intrinsic_function( - scope=binding_scope, - intrinsic_function=binding_name, - before_arguments=before_value, - after_arguments=after_value, - ) - else: - value = self._visit_value( - scope=binding_scope, before_value=before_value, after_value=after_value - ) + value = self._visit_value( + scope=binding_scope, before_value=before_value, after_value=after_value + ) bindings[binding_name] = value change_type = change_type.for_child(value.change_type) node_object = NodeObject(scope=scope, change_type=change_type, bindings=bindings) @@ -601,8 +629,11 @@ def _visit_value( value = self._visited_scopes.get(scope) if isinstance(value, ChangeSetEntity): return value + + before_type_name = self._type_name_of(before_value) + after_type_name = self._type_name_of(after_value) unset = object() - if type(before_value) is type(after_value): + if before_type_name == after_type_name: dominant_value = before_value elif self._is_created(before=before_value, after=after_value): dominant_value = after_value @@ -611,6 +642,7 @@ def _visit_value( else: dominant_value = unset if dominant_value is not unset: + dominant_type_name = self._type_name_of(dominant_value) if self._is_terminal(value=dominant_value): value = self._visit_terminal_value( scope=scope, before_value=before_value, after_value=after_value @@ -623,6 +655,16 @@ def _visit_value( value = self._visit_array( scope=scope, before_array=before_value, after_array=after_value ) + elif self._is_intrinsic_function_name(dominant_type_name): + intrinsic_function_scope, (before_arguments, after_arguments) = ( + self._safe_access_in(scope, dominant_type_name, before_value, after_value) + ) + value = self._visit_intrinsic_function( + scope=scope, + intrinsic_function=dominant_type_name, + before_arguments=before_arguments, + after_arguments=after_arguments, + ) else: raise RuntimeError(f"Unsupported type {type(dominant_value)}") # Case: type divergence. @@ -717,12 +759,15 @@ def _visit_resource( # TODO: investigate behaviour with type changes, for now this is filler code. _, type_str = self._safe_access_in(scope, TypeKey, before_resource) + condition_reference = None scope_condition, (before_condition, after_condition) = self._safe_access_in( scope, ConditionKey, before_resource, after_resource ) - condition_reference = self._visit_terminal_value( - scope_condition, before_condition, after_condition - ) + # TODO: condition references should be resolved for the condition's change_type? + if before_condition or after_condition: + condition_reference = self._visit_terminal_value( + scope_condition, before_condition, after_condition + ) scope_properties, (before_properties, after_properties) = self._safe_access_in( scope, PropertiesKey, before_resource, after_resource @@ -887,18 +932,9 @@ def _visit_condition( node_condition = self._visited_scopes.get(scope) if isinstance(node_condition, NodeCondition): return node_condition - - # TODO: is schema validation/check necessary or can we trust the input at this point? - function_names: list[str] = self._safe_keys_of(before_condition, after_condition) - if len(function_names) == 1: - body = self._visit_object( - scope=scope, before_object=before_condition, after_object=after_condition - ) - else: - body = self._visit_divergence( - scope=scope, before_value=before_condition, after_value=after_condition - ) - + body = self._visit_value( + scope=scope, before_value=before_condition, after_value=after_condition + ) node_condition = NodeCondition( scope=scope, change_type=body.change_type, name=condition_name, body=body ) @@ -932,6 +968,64 @@ def _visit_conditions( self._visited_scopes[scope] = node_conditions return node_conditions + def _visit_output( + self, scope: Scope, name: str, before_output: Maybe[dict], after_output: Maybe[dict] + ) -> NodeOutput: + change_type = ChangeType.UNCHANGED + scope_value, (before_value, after_value) = self._safe_access_in( + scope, ValueKey, before_output, after_output + ) + value = self._visit_value(scope_value, before_value, after_value) + change_type = change_type.for_child(value.change_type) + + export: Optional[ChangeSetEntity] = None + scope_export, (before_export, after_export) = self._safe_access_in( + scope, ExportKey, before_output, after_output + ) + if before_export or after_export: + export = self._visit_value(scope_export, before_export, after_export) + change_type = change_type.for_child(export.change_type) + + # TODO: condition references should be resolved for the condition's change_type? + condition_reference: Optional[TerminalValue] = None + scope_condition, (before_condition, after_condition) = self._safe_access_in( + scope, ConditionKey, before_output, after_output + ) + if before_condition or after_condition: + condition_reference = self._visit_terminal_value( + scope_condition, before_condition, after_condition + ) + change_type = change_type.for_child(condition_reference.change_type) + + return NodeOutput( + scope=scope, + change_type=change_type, + name=name, + value=value, + export=export, + conditional_reference=condition_reference, + ) + + def _visit_outputs( + self, scope: Scope, before_outputs: Maybe[dict], after_outputs: Maybe[dict] + ) -> NodeOutputs: + change_type = ChangeType.UNCHANGED + outputs: list[NodeOutput] = list() + output_names: list[str] = self._safe_keys_of(before_outputs, after_outputs) + for output_name in output_names: + scope_output, (before_output, after_output) = self._safe_access_in( + scope, output_name, before_outputs, after_outputs + ) + output = self._visit_output( + scope=scope_output, + name=output_name, + before_output=before_output, + after_output=after_output, + ) + outputs.append(output) + change_type = change_type.for_child(output.change_type) + return NodeOutputs(scope=scope, change_type=change_type, outputs=outputs) + def _model(self, before_template: Maybe[dict], after_template: Maybe[dict]) -> NodeTemplate: root_scope = Scope() # TODO: visit other child types @@ -970,6 +1064,13 @@ def _model(self, before_template: Maybe[dict], after_template: Maybe[dict]) -> N after_resources=after_resources, ) + outputs_scope, (before_outputs, after_outputs) = self._safe_access_in( + root_scope, OutputsKey, before_template, after_template + ) + outputs = self._visit_outputs( + scope=outputs_scope, before_outputs=before_outputs, after_outputs=after_outputs + ) + # TODO: compute the change_type of the template properly. return NodeTemplate( scope=root_scope, @@ -978,6 +1079,7 @@ def _model(self, before_template: Maybe[dict], after_template: Maybe[dict]) -> N parameters=parameters, conditions=conditions, resources=resources, + outputs=outputs, ) def _retrieve_condition_if_exists(self, condition_name: str) -> Optional[NodeCondition]: @@ -1090,13 +1192,30 @@ def _change_type_for_parent_of(change_types: list[ChangeType]) -> ChangeType: break return parent_change_type + @staticmethod + def _name_if_intrinsic_function(value: Maybe[Any]) -> Optional[str]: + if isinstance(value, dict): + keys = ChangeSetModel._safe_keys_of(value) + if len(keys) == 1: + key_name = keys[0] + if ChangeSetModel._is_intrinsic_function_name(key_name): + return key_name + return None + + @staticmethod + def _type_name_of(value: Maybe[Any]) -> str: + maybe_intrinsic_function_name = ChangeSetModel._name_if_intrinsic_function(value) + if maybe_intrinsic_function_name is not None: + return maybe_intrinsic_function_name + return type(value).__name__ + @staticmethod def _is_terminal(value: Any) -> bool: return type(value) in {int, float, bool, str, None, NothingType} @staticmethod def _is_object(value: Any) -> bool: - return isinstance(value, dict) + return isinstance(value, dict) and ChangeSetModel._name_if_intrinsic_function(value) is None @staticmethod def _is_array(value: Any) -> bool: diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py index fbda4d6c3fa5f..4e9dcdd6369e2 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py @@ -7,12 +7,16 @@ from localstack.services.cloudformation.engine.v2.change_set_model import ( ChangeSetEntity, ChangeType, + ConditionKey, + ExportKey, NodeArray, NodeCondition, NodeDivergence, NodeIntrinsicFunction, NodeMapping, NodeObject, + NodeOutput, + NodeOutputs, NodeParameter, NodeProperties, NodeProperty, @@ -26,6 +30,7 @@ TerminalValueModified, TerminalValueRemoved, TerminalValueUnchanged, + ValueKey, ) from localstack.services.cloudformation.engine.v2.change_set_model_visitor import ( ChangeSetModelVisitor, @@ -112,12 +117,13 @@ def _resolve_reference(self, logica_id: str) -> DescribeUnit: return parameter_unit # TODO: check for KNOWN AFTER APPLY values for logical ids coming from intrinsic functions as arguments. - # node_resource = self._get_node_resource_for( - # resource_name=logica_id, node_template=self._node_template - # ) - limitation_str = "Cannot yet compute Ref values for Resources" - resource_unit = DescribeUnit(before_context=limitation_str, after_context=limitation_str) - return resource_unit + node_resource = self._get_node_resource_for( + resource_name=logica_id, node_template=self._node_template + ) + resource_unit = self.visit(node_resource) + before_context = resource_unit.before_context + after_context = resource_unit.after_context + return DescribeUnit(before_context=before_context, after_context=after_context) def _resolve_mapping(self, map_name: str, top_level_key: str, second_level_key) -> DescribeUnit: # TODO: add support for nested intrinsic functions, and KNOWN AFTER APPLY logical ids. @@ -210,29 +216,37 @@ def visit_node_intrinsic_function_fn_get_att( arguments_unit = self.visit(node_intrinsic_function.arguments) # TODO: validate the return value according to the spec. before_argument_list = arguments_unit.before_context - before_logical_name_of_resource = before_argument_list[0] - before_attribute_name = before_argument_list[1] - before_node_resource = self._get_node_resource_for( - resource_name=before_logical_name_of_resource, node_template=self._node_template - ) - node_property: TerminalValue = self._get_node_property_for( - property_name=before_attribute_name, node_resource=before_node_resource - ) + after_argument_list = arguments_unit.after_context - before_context = node_property.value.value - if node_property.change_type != ChangeType.UNCHANGED: + before_context = None + if before_argument_list: + before_logical_name_of_resource = before_argument_list[0] + before_attribute_name = before_argument_list[1] + before_node_resource = self._get_node_resource_for( + resource_name=before_logical_name_of_resource, node_template=self._node_template + ) + before_node_property = self._get_node_property_for( + property_name=before_attribute_name, node_resource=before_node_resource + ) + before_property_unit = self.visit(before_node_property) + before_context = before_property_unit.before_context + + after_context = None + if after_argument_list: after_context = CHANGESET_KNOWN_AFTER_APPLY - else: - after_context = node_property.value.value + # TODO: the following is the logic to resolve the attribute in the `after` template + # this should be moved to the new base class and then be masked in this describer. + # after_logical_name_of_resource = after_argument_list[0] + # after_attribute_name = after_argument_list[1] + # after_node_resource = self._get_node_resource_for( + # resource_name=after_logical_name_of_resource, node_template=self._node_template + # ) + # after_node_property = self._get_node_property_for( + # property_name=after_attribute_name, node_resource=after_node_resource + # ) + # after_property_unit = self.visit(after_node_property) + # after_context = after_property_unit.after_context - match node_intrinsic_function.change_type: - case ChangeType.MODIFIED: - return DescribeUnit(before_context=before_context, after_context=after_context) - case ChangeType.CREATED: - return DescribeUnit(after_context=after_context) - case ChangeType.REMOVED: - return DescribeUnit(before_context=before_context) - # Unchanged return DescribeUnit(before_context=before_context, after_context=after_context) def visit_node_intrinsic_function_fn_equals( @@ -342,12 +356,16 @@ def visit_node_intrinsic_function_ref( # TODO: add tests with created and deleted parameters and verify this logic holds. before_logical_id = arguments_unit.before_context - before_unit = self._resolve_reference(logica_id=before_logical_id) - before_context = before_unit.before_context + before_context = None + if before_logical_id is not None: + before_unit = self._resolve_reference(logica_id=before_logical_id) + before_context = before_unit.before_context after_logical_id = arguments_unit.after_context - after_unit = self._resolve_reference(logica_id=after_logical_id) - after_context = after_unit.after_context + after_context = None + if after_logical_id is not None: + after_unit = self._resolve_reference(logica_id=after_logical_id) + after_context = after_unit.after_context return DescribeUnit(before_context=before_context, after_context=after_context) @@ -406,21 +424,71 @@ def _resolve_resource_condition_reference(self, reference: TerminalValue) -> Des ) return DescribeUnit(before_context=before_context, after_context=after_context) + def visit_node_output(self, node_output: NodeOutput) -> DescribeUnit: + # This logic is not required for Describe operations, + # and should be ported a new base for this class type. + change_type = node_output.change_type + value_unit = self.visit(node_output.value) + + condition_unit = None + if node_output.condition_reference is not None: + condition_unit = self._resolve_resource_condition_reference( + node_output.condition_reference + ) + condition_before = condition_unit.before_context + condition_after = condition_unit.after_context + if not condition_before and condition_after: + change_type = ChangeType.CREATED + elif condition_before and not condition_after: + change_type = ChangeType.REMOVED + + export_unit = None + if node_output.export is not None: + export_unit = self.visit(node_output.export) + + before_context = None + after_context = None + if change_type != ChangeType.REMOVED: + after_context = {"Name": node_output.name, ValueKey: value_unit.after_context} + if export_unit: + after_context[ExportKey] = export_unit.after_context + if condition_unit: + after_context[ConditionKey] = condition_unit.after_context + if change_type != ChangeType.CREATED: + before_context = {"Name": node_output.name, ValueKey: value_unit.before_context} + if export_unit: + before_context[ExportKey] = export_unit.before_context + if condition_unit: + before_context[ConditionKey] = condition_unit.before_context + return DescribeUnit(before_context=before_context, after_context=after_context) + + def visit_node_outputs(self, node_outputs: NodeOutputs) -> DescribeUnit: + # This logic is not required for Describe operations, + # and should be ported a new base for this class type. + before_context = list() + after_context = list() + for node_output in node_outputs.outputs: + output_unit = self.visit(node_output) + output_before = output_unit.before_context + output_after = output_unit.after_context + if output_before: + before_context.append(output_before) + if output_after: + after_context.append(output_after) + return DescribeUnit(before_context=before_context, after_context=after_context) + def visit_node_resource(self, node_resource: NodeResource) -> DescribeUnit: - condition_unit = self._resolve_resource_condition_reference( - node_resource.condition_reference - ) - condition_before = condition_unit.before_context - condition_after = condition_unit.after_context - if not condition_before and condition_after: - change_type = ChangeType.CREATED - elif condition_before and not condition_after: - change_type = ChangeType.REMOVED - else: - change_type = node_resource.change_type - if change_type == ChangeType.UNCHANGED: - # TODO - return None + change_type = node_resource.change_type + if node_resource.condition_reference is not None: + condition_unit = self._resolve_resource_condition_reference( + node_resource.condition_reference + ) + condition_before = condition_unit.before_context + condition_after = condition_unit.after_context + if not condition_before and condition_after: + change_type = ChangeType.CREATED + elif condition_before and not condition_after: + change_type = ChangeType.REMOVED resource_change = cfn_api.ResourceChange() resource_change["LogicalResourceId"] = node_resource.name @@ -432,28 +500,28 @@ def visit_node_resource(self, node_resource: NodeResource) -> DescribeUnit: ) properties_describe_unit = self.visit(node_resource.properties) - match change_type: - case ChangeType.MODIFIED: - resource_change["Action"] = cfn_api.ChangeAction.Modify - resource_change["BeforeContext"] = properties_describe_unit.before_context - resource_change["AfterContext"] = properties_describe_unit.after_context - case ChangeType.CREATED: - resource_change["Action"] = cfn_api.ChangeAction.Add - resource_change["AfterContext"] = properties_describe_unit.after_context - case ChangeType.REMOVED: - resource_change["Action"] = cfn_api.ChangeAction.Remove - resource_change["BeforeContext"] = properties_describe_unit.before_context - - self._changes.append( - cfn_api.Change(Type=cfn_api.ChangeType.Resource, ResourceChange=resource_change) - ) - # TODO - return None + if change_type != ChangeType.UNCHANGED: + match change_type: + case ChangeType.MODIFIED: + resource_change["Action"] = cfn_api.ChangeAction.Modify + resource_change["BeforeContext"] = properties_describe_unit.before_context + resource_change["AfterContext"] = properties_describe_unit.after_context + case ChangeType.CREATED: + resource_change["Action"] = cfn_api.ChangeAction.Add + resource_change["AfterContext"] = properties_describe_unit.after_context + case ChangeType.REMOVED: + resource_change["Action"] = cfn_api.ChangeAction.Remove + resource_change["BeforeContext"] = properties_describe_unit.before_context + self._changes.append( + cfn_api.Change(Type=cfn_api.ChangeType.Resource, ResourceChange=resource_change) + ) - # def visit_node_resources(self, node_resources: NodeResources) -> DescribeUnit: - # for node_resource in node_resources.resources: - # if node_resource.change_type != ChangeType.UNCHANGED: - # self.visit_node_resource(node_resource=node_resource) - # # TODO - # return None + before_context = None + after_context = None + # TODO: reconsider what is the describe unit return value for a resource type. + if change_type != ChangeType.CREATED: + before_context = node_resource.name + if change_type != ChangeType.REMOVED: + after_context = node_resource.name + return DescribeUnit(before_context=before_context, after_context=after_context) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py index 8a167979fb177..c7340cac44c4b 100644 --- a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py @@ -10,6 +10,8 @@ NodeMapping, NodeMappings, NodeObject, + NodeOutput, + NodeOutputs, NodeParameter, NodeParameters, NodeProperties, @@ -53,6 +55,12 @@ def visit_node_mapping(self, node_mapping: NodeMapping): def visit_node_mappings(self, node_mappings: NodeMappings): self.visit_children(node_mappings) + def visit_node_outputs(self, node_outputs: NodeOutputs): + self.visit_children(node_outputs) + + def visit_node_output(self, node_output: NodeOutput): + self.visit_children(node_output) + def visit_node_parameters(self, node_parameters: NodeParameters): self.visit_children(node_parameters) diff --git a/tests/unit/services/cloudformation/test_change_set_describe_details.py b/tests/unit/services/cloudformation/test_change_set_describe_details.py index d8cf98ff5b757..00df073c977a6 100644 --- a/tests/unit/services/cloudformation/test_change_set_describe_details.py +++ b/tests/unit/services/cloudformation/test_change_set_describe_details.py @@ -10,6 +10,7 @@ ) from localstack.services.cloudformation.engine.v2.change_set_model_describer import ( ChangeSetModelDescriber, + DescribeUnit, ) @@ -36,6 +37,23 @@ def eval_change_set( json_str = json.dumps(changes) return json.loads(json_str) + @staticmethod + def debug_outputs( + before_template: Optional[dict], + after_template: Optional[dict], + before_parameters: Optional[dict] = None, + after_parameters: Optional[dict] = None, + ) -> DescribeUnit: + change_set_model = ChangeSetModel( + before_template=before_template, + after_template=after_template, + before_parameters=before_parameters, + after_parameters=after_parameters, + ) + update_model: NodeTemplate = change_set_model.get_update_model() + outputs_unit = ChangeSetModelDescriber(update_model).visit(update_model.outputs) + return outputs_unit + @staticmethod def compare_changes(computed: list, target: list) -> None: def sort_criteria(resource_change): @@ -517,6 +535,7 @@ def test_parameter_dynamic_change_unrelated_property(self): "Parameter1": { "Type": "AWS::SSM::Parameter", "Properties": { + "Name": "param-name", "Type": "String", "Value": {"Ref": "ParameterValue"}, }, @@ -574,8 +593,12 @@ def test_parameter_dynamic_change_unrelated_property(self): # "ChangeSource": "DirectModification" # } # ], - "BeforeContext": {"Properties": {"Value": "value-1", "Type": "String"}}, - "AfterContext": {"Properties": {"Value": "value-2", "Type": "String"}}, + "BeforeContext": { + "Properties": {"Name": "param-name", "Value": "value-1", "Type": "String"} + }, + "AfterContext": { + "Properties": {"Name": "param-name", "Value": "value-2", "Type": "String"} + }, }, } ] @@ -1454,3 +1477,204 @@ def test_mappings_update_referencing_resource_through_parameter(self): } ] self.compare_changes(changes, target) + + def test_output_new_resource_and_output(self): + t1 = { + "Resources": { + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + } + } + } + t2 = { + "Resources": { + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + "NewParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "param-name", "Type": "String", "Value": "value-1"}, + }, + }, + "Outputs": {"NewParamName": {"Value": {"Ref": "NewParam"}}}, + } + outputs_unit = self.debug_outputs(t1, t2) + assert not outputs_unit.before_context + # NOTE: Outputs are currently evaluated by the describer using the entity name as a proxy, + # as the executor logic is not yet implemented. This will be moved to the template processor. + assert outputs_unit.after_context == [{"Name": "NewParamName", "Value": "NewParam"}] + + def test_output_and_resource_removed(self): + t1 = { + "Resources": { + "FeatureToggle": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": "app-feature-toggle", + "Type": "String", + "Value": "enabled", + }, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"FeatureToggleName": {"Value": {"Ref": "FeatureToggle"}}}, + } + t2 = { + "Resources": { + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + } + } + } + outputs_unit = self.debug_outputs(t1, t2) + # NOTE: Outputs are currently evaluated by the describer using the entity name as a proxy, + # as the executor logic is not yet implemented. This will be moved to the template processor. + assert outputs_unit.before_context == [ + {"Name": "FeatureToggleName", "Value": "FeatureToggle"} + ] + assert outputs_unit.after_context == [] + + def test_output_resource_changed(self): + t1 = { + "Resources": { + "LogLevelParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "app-log-level", "Type": "String", "Value": "info"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"LogLevelOutput": {"Value": {"Ref": "LogLevelParam"}}}, + } + t2 = { + "Resources": { + "LogLevelParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "app-log-level", "Type": "String", "Value": "debug"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"LogLevelOutput": {"Value": {"Ref": "LogLevelParam"}}}, + } + outputs_unit = self.debug_outputs(t1, t2) + # NOTE: Outputs are currently evaluated by the describer using the entity name as a proxy, + # as the executor logic is not yet implemented. This will be moved to the template processor. + assert outputs_unit.before_context == [{"Name": "LogLevelOutput", "Value": "LogLevelParam"}] + assert outputs_unit.after_context == [{"Name": "LogLevelOutput", "Value": "LogLevelParam"}] + + def test_output_update(self): + t1 = { + "Resources": { + "EnvParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "app-env", "Type": "String", "Value": "prod"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"EnvParamRef": {"Value": {"Ref": "EnvParam"}}}, + } + + t2 = { + "Resources": { + "EnvParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "app-env", "Type": "String", "Value": "prod"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"EnvParamRef": {"Value": {"Fn::GetAtt": ["EnvParam", "Name"]}}}, + } + outputs_unit = self.debug_outputs(t1, t2) + # NOTE: Outputs are currently evaluated by the describer using the entity name as a proxy, + # as the executor logic is not yet implemented. This will be moved to the template processor. + assert outputs_unit.before_context == [{"Name": "EnvParamRef", "Value": "EnvParam"}] + assert outputs_unit.after_context == [ + {"Name": "EnvParamRef", "Value": "{{changeSet:KNOWN_AFTER_APPLY}}"} + ] + + def test_output_renamed(self): + t1 = { + "Resources": { + "SSMParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "some-param", "Type": "String", "Value": "value"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"OldSSMOutput": {"Value": {"Ref": "SSMParam"}}}, + } + t2 = { + "Resources": { + "SSMParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "some-param", "Type": "String", "Value": "value"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"NewSSMOutput": {"Value": {"Ref": "SSMParam"}}}, + } + outputs_unit = self.debug_outputs(t1, t2) + # NOTE: Outputs are currently evaluated by the describer using the entity name as a proxy, + # as the executor logic is not yet implemented. This will be moved to the template processor. + assert outputs_unit.before_context == [{"Name": "OldSSMOutput", "Value": "SSMParam"}] + assert outputs_unit.after_context == [{"Name": "NewSSMOutput", "Value": "SSMParam"}] + + def test_output_and_resource_renamed(self): + t1 = { + "Resources": { + "DBPasswordParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "db-password", "Type": "String", "Value": "secret"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"DBPasswordOutput": {"Value": {"Ref": "DBPasswordParam"}}}, + } + t2 = { + "Resources": { + "DatabaseSecretParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "db-password", "Type": "String", "Value": "secret"}, + }, + "UnrelatedParam": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Name": "unrelated-param", "Type": "String", "Value": "foo"}, + }, + }, + "Outputs": {"DatabaseSecretOutput": {"Value": {"Ref": "DatabaseSecretParam"}}}, + } + outputs_unit = self.debug_outputs(t1, t2) + # NOTE: Outputs are currently evaluated by the describer using the entity name as a proxy, + # as the executor logic is not yet implemented. This will be moved to the template processor. + assert outputs_unit.before_context == [ + {"Name": "DBPasswordOutput", "Value": "DBPasswordParam"} + ] + assert outputs_unit.after_context == [ + {"Name": "DatabaseSecretOutput", "Value": "DatabaseSecretParam"} + ]