From 46a50d4baef12397c63c208b9d68580e1b622bd6 Mon Sep 17 00:00:00 2001 From: Vichy Meas Date: Tue, 23 Jun 2026 01:37:05 +0000 Subject: [PATCH 1/6] feat(globals): add per-property merge strategy with dot-notation schema Add a strategy pattern to the Globals merge engine that allows declaring per-property merge behavior via a dot-notation schema. Day-1 scope: Function.Architectures uses REPLACE (local list wins entirely). The engine remains fully backward-compatible: properties not listed in CUSTOM_STRATEGIES default to CONCATENATE (today's behavior). Path-aware recursion tracks the current property path and consults the schema only at LIST+LIST merge nodes. New module: samtranslator/plugins/globals/merge_strategy.py - MergeOp enum: CONCATENATE, REPLACE, MERGE_BY_KEY - MergeRule frozen dataclass with validation - merge_by_key() factory function Resolves: https://github.com/aws/serverless-application-model/issues/3939 --- samtranslator/plugins/globals/globals.py | 64 ++++++++-- .../plugins/globals/merge_strategy.py | 32 +++++ tests/plugins/globals/test_globals.py | 61 ++++++++- tests/plugins/globals/test_merge_strategy.py | 90 ++++++++++++++ .../globals_merge_strategy_architectures.yaml | 21 ++++ .../globals_merge_strategy_architectures.json | 116 ++++++++++++++++++ .../globals_merge_strategy_architectures.json | 116 ++++++++++++++++++ .../globals_merge_strategy_architectures.json | 116 ++++++++++++++++++ 8 files changed, 608 insertions(+), 8 deletions(-) create mode 100644 samtranslator/plugins/globals/merge_strategy.py create mode 100644 tests/plugins/globals/test_merge_strategy.py create mode 100644 tests/translator/input/globals_merge_strategy_architectures.yaml create mode 100644 tests/translator/output/aws-cn/globals_merge_strategy_architectures.json create mode 100644 tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json create mode 100644 tests/translator/output/globals_merge_strategy_architectures.json diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index 83940b03e4..4f72079fce 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -2,10 +2,14 @@ from typing import Any, Union from samtranslator.model.exceptions import ExceptionWithMessage, InvalidResourceAttributeTypeException +from samtranslator.plugins.globals.merge_strategy import REPLACE, MergeOp, MergeRule from samtranslator.public.intrinsics import is_intrinsics from samtranslator.public.sdk.resource import SamResourceType from samtranslator.swagger.swagger import SwaggerEditor +# Per-property merge schema. Paths not listed here default to CONCATENATE (today's behavior). +CUSTOM_STRATEGIES: dict[str, MergeRule] = {"Function.Architectures": REPLACE} + class Globals: """ @@ -307,7 +311,12 @@ def _parse(self, globals_dict): # type: ignore[no-untyped-def] ) # Store all Global properties in a map with key being the AWS::Serverless::* resource type - _globals[resource_type] = GlobalProperties(properties) + resource_schema = { + k.removeprefix(f"{section_name}."): v + for k, v in CUSTOM_STRATEGIES.items() + if k.startswith(f"{section_name}.") + } + _globals[resource_type] = GlobalProperties(properties, schema=resource_schema) return _globals @@ -435,8 +444,9 @@ class GlobalProperties: """ - def __init__(self, global_properties) -> None: # type: ignore[no-untyped-def] + def __init__(self, global_properties, schema=None) -> None: # type: ignore[no-untyped-def] self.global_properties = global_properties + self.schema = schema or {} def merge(self, local_properties): # type: ignore[no-untyped-def] """ @@ -444,15 +454,16 @@ def merge(self, local_properties): # type: ignore[no-untyped-def] :return local_properties: Dictionary of local properties """ - return self._do_merge(self.global_properties, local_properties) # type: ignore[no-untyped-call] + return self._do_merge(self.global_properties, local_properties, path="") # type: ignore[no-untyped-call] - def _do_merge(self, global_value, local_value): # type: ignore[no-untyped-def] + def _do_merge(self, global_value, local_value, path=""): # type: ignore[no-untyped-def] """ Actually perform the merge operation for the given inputs. This method is used as part of the recursion. Therefore input values can be of any type. So is the output. :param global_value: Global value to be merged :param local_value: Local value to be merged + :param path: Dot-delimited path for schema lookup :return: Merged result """ @@ -467,9 +478,14 @@ def _do_merge(self, global_value, local_value): # type: ignore[no-untyped-def] return self._prefer_local(global_value, local_value) # type: ignore[no-untyped-call] if self.TOKEN.DICT == token_global == token_local: - return self._merge_dict(global_value, local_value) # type: ignore[no-untyped-call] + return self._merge_dict(global_value, local_value, path) # type: ignore[no-untyped-call] if self.TOKEN.LIST == token_global == token_local: + rule = self.schema.get(path) + if rule and rule.op == MergeOp.REPLACE: + return local_value + if rule and rule.op == MergeOp.MERGE_BY_KEY: + return self._merge_by_key(global_value, local_value, rule.key) return self._merge_lists(global_value, local_value) # type: ignore[no-untyped-call] raise TypeError(f"Unsupported type of objects. GlobalType={token_global}, LocalType={token_local}") @@ -485,12 +501,45 @@ def _merge_lists(self, global_list, local_list): # type: ignore[no-untyped-def] return global_list + local_list - def _merge_dict(self, global_dict, local_dict): # type: ignore[no-untyped-def] + def _merge_by_key(self, global_list: list[Any], local_list: list[Any], key: str | None) -> list[Any]: + """ + Merges two lists of dicts by a shared key field. Local entries override global entries + with the same key value. Non-dict items and items without the key are preserved. + + :param global_list: Global list of dicts + :param local_list: Local list of dicts + :param key: The dict key to match on + :return: Merged list + """ + local_by_key = {item[key]: item for item in local_list if isinstance(item, dict) and key in item} + seen_keys: set[Any] = set() + result = [] + + # Pass 1: walk globals, replace matched keys with local override + for item in global_list: + if isinstance(item, dict) and key in item and item[key] in local_by_key: + result.append(local_by_key[item[key]]) + seen_keys.add(item[key]) + else: + result.append(item) + + # Pass 2: append local items not already seen (new keys + non-dict overflow) + for item in local_list: + if isinstance(item, dict) and key in item: + if item[key] not in seen_keys: + result.append(item) + else: + result.append(item) + + return result + + def _merge_dict(self, global_dict, local_dict, path_prefix=""): # type: ignore[no-untyped-def] """ Merges the two dictionaries together :param global_dict: Global dictionary to be merged :param local_dict: Local dictionary to be merged + :param path_prefix: Current dot-delimited path prefix for schema lookup :return: New merged dictionary with values shallow copied """ @@ -498,9 +547,10 @@ def _merge_dict(self, global_dict, local_dict): # type: ignore[no-untyped-def] global_dict = global_dict.copy() for key in local_dict: + child_path = f"{path_prefix}.{key}".lstrip(".") if key in global_dict: # Both local & global contains the same key. Let's do a merge. - global_dict[key] = self._do_merge(global_dict[key], local_dict[key]) # type: ignore[no-untyped-call] + global_dict[key] = self._do_merge(global_dict[key], local_dict[key], child_path) # type: ignore[no-untyped-call] else: # Key is not in globals, just in local. Copy it over diff --git a/samtranslator/plugins/globals/merge_strategy.py b/samtranslator/plugins/globals/merge_strategy.py new file mode 100644 index 0000000000..159b78050b --- /dev/null +++ b/samtranslator/plugins/globals/merge_strategy.py @@ -0,0 +1,32 @@ +"""Per-property merge strategy types for the Globals merge engine.""" + +from dataclasses import dataclass +from enum import Enum + + +class MergeOp(Enum): + CONCATENATE = "concatenate" + REPLACE = "replace" + MERGE_BY_KEY = "merge_by_key" + + +@dataclass(frozen=True) +class MergeRule: + op: MergeOp + key: str | None = None + + def __post_init__(self) -> None: + if self.op == MergeOp.MERGE_BY_KEY and not self.key: + raise ValueError("MERGE_BY_KEY requires a 'key' field") + if self.op != MergeOp.MERGE_BY_KEY and self.key is not None: + raise ValueError(f"'key' is only valid with MERGE_BY_KEY, not {self.op.value}") + + +# Explicit default; not needed in CUSTOM_STRATEGIES (unlisted paths already concatenate). +CONCATENATE = MergeRule(MergeOp.CONCATENATE) +REPLACE = MergeRule(MergeOp.REPLACE) + + +def merge_by_key(key: str) -> MergeRule: + """Factory for MERGE_BY_KEY rules. Merges list-of-dicts by the named key field.""" + return MergeRule(MergeOp.MERGE_BY_KEY, key=key) diff --git a/tests/plugins/globals/test_globals.py b/tests/plugins/globals/test_globals.py index 9178244668..57f5626869 100644 --- a/tests/plugins/globals/test_globals.py +++ b/tests/plugins/globals/test_globals.py @@ -4,6 +4,7 @@ from parameterized import parameterized from samtranslator.model.exceptions import InvalidResourceAttributeTypeException from samtranslator.plugins.globals.globals import GlobalProperties, Globals, InvalidGlobalsSectionException +from samtranslator.plugins.globals.merge_strategy import REPLACE, merge_by_key class GlobalPropertiesTestCases: @@ -171,6 +172,42 @@ class GlobalPropertiesTestCases: mixed_type_inputs_must_be_handled = {"global": {"a": "b"}, "local": [1, 2, 3], "expected_output": [1, 2, 3]} + # Merge strategy: REPLACE — local list fully replaces global list (flat and nested paths). + # Add new test cases here when new rules are added to CUSTOM_STRATEGIES. + list_with_replace_strategy_must_use_local = { + "global": {"Architectures": ["x86_64"], "VpcConfig": {"SecurityGroupIds": ["sg-global"]}}, + "local": {"Architectures": ["arm64"], "VpcConfig": {"SecurityGroupIds": ["sg-local"]}}, + "expected_output": {"Architectures": ["arm64"], "VpcConfig": {"SecurityGroupIds": ["sg-local"]}}, + "schema": {"Architectures": REPLACE, "VpcConfig.SecurityGroupIds": REPLACE}, + } + + # Merge strategy: MERGE_BY_KEY — deduplicates by key field, local overrides; non-dict items preserved. + list_with_merge_by_key_strategy = { + "global": {"Tags": [{"Key": "env", "Value": "dev"}, {"Key": "team", "Value": "lambda"}, "plain-string"]}, + "local": {"Tags": [{"Key": "env", "Value": "prod"}, {"Key": "app", "Value": "my"}]}, + "expected_output": { + "Tags": [ + {"Key": "env", "Value": "prod"}, + {"Key": "team", "Value": "lambda"}, + "plain-string", + {"Key": "app", "Value": "my"}, + ] + }, + "schema": {"Tags": merge_by_key("Key")}, + } + + # Multiple strategies applied to different properties in one merge. + multiple_strategies_applied_per_property = { + "global": {"Architectures": ["x86_64"], "Tags": [{"Key": "env", "Value": "dev"}], "Layers": ["arn:layer1"]}, + "local": {"Architectures": ["arm64"], "Tags": [{"Key": "env", "Value": "prod"}], "Layers": ["arn:layer2"]}, + "expected_output": { + "Architectures": ["arm64"], + "Tags": [{"Key": "env", "Value": "prod"}], + "Layers": ["arn:layer1", "arn:layer2"], + }, + "schema": {"Architectures": REPLACE, "Tags": merge_by_key("Key")}, + } + class TestGlobalPropertiesMerge(TestCase): # Get all attributes of the test case object which is not a built-in method like __str__ @@ -180,7 +217,8 @@ def test_global_properties_merge(self, testcase): if not configuration: raise Exception("Invalid configuration for test case " + testcase) - global_properties = GlobalProperties(configuration["global"]) + schema = configuration.get("schema", {}) + global_properties = GlobalProperties(configuration["global"], schema=schema) actual = global_properties.merge(configuration["local"]) self.assertEqual(actual, configuration["expected_output"]) @@ -532,3 +570,24 @@ def test_openapi_postprocess(self): global_obj = Globals(self.template) global_obj.fix_openapi_definitions(test["input"]) self.assertEqual(test["input"], test["expected"], test["name"]) + + +class TestMergeSchemaWiring(TestCase): + """Tests that require the full Globals(template) pipeline (schema slicing by resource type). + + Add new test cases to GlobalPropertiesTestCases for merge behavior. + Only add here for wiring-specific tests (IgnoreGlobals interaction, resource-type routing). + """ + + def test_ignore_globals_skips_schema(self): + """IgnoreGlobals for a registered property means schema is never consulted.""" + template = {"Globals": {"Function": {"Architectures": ["arm64"], "Runtime": "python3.12"}}} + g = Globals(template) + result = g.merge( + "AWS::Serverless::Function", + {"Architectures": ["x86_64"]}, + logical_id="MyFunc", + ignore_globals=["Architectures"], + ) + self.assertEqual(result["Architectures"], ["x86_64"]) + self.assertEqual(result["Runtime"], "python3.12") diff --git a/tests/plugins/globals/test_merge_strategy.py b/tests/plugins/globals/test_merge_strategy.py new file mode 100644 index 0000000000..182bde2801 --- /dev/null +++ b/tests/plugins/globals/test_merge_strategy.py @@ -0,0 +1,90 @@ +"""Unit tests for merge_strategy.py types.""" + +import unittest + +from parameterized import parameterized +from samtranslator.plugins.globals.merge_strategy import ( + CONCATENATE, + REPLACE, + MergeOp, + MergeRule, + merge_by_key, +) + + +class TestMergeOp(unittest.TestCase): + @parameterized.expand( + [ + ("concatenate", MergeOp.CONCATENATE, "concatenate"), + ("replace", MergeOp.REPLACE, "replace"), + ("merge_by_key", MergeOp.MERGE_BY_KEY, "merge_by_key"), + ] + ) + def test_enum_values(self, _name, member, expected): + self.assertEqual(member.value, expected) + + +class TestMergeRule(unittest.TestCase): + @parameterized.expand( + [ + ("replace", MergeOp.REPLACE, None), + ("concatenate", MergeOp.CONCATENATE, None), + ("merge_by_key", MergeOp.MERGE_BY_KEY, "Key"), + ] + ) + def test_valid_creation(self, _name, op, key): + rule = MergeRule(op, key=key) if key else MergeRule(op) + self.assertEqual(rule.op, op) + self.assertEqual(rule.key, key) + + @parameterized.expand( + [ + ("merge_by_key_no_key", MergeOp.MERGE_BY_KEY, None, "MERGE_BY_KEY requires a 'key' field"), + ("replace_with_key", MergeOp.REPLACE, "Bad", "only valid with MERGE_BY_KEY"), + ("concatenate_with_key", MergeOp.CONCATENATE, "Bad", "only valid with MERGE_BY_KEY"), + ] + ) + def test_invalid_creation_raises(self, _name, op, key, expected_msg): + with self.assertRaises(ValueError) as ctx: + MergeRule(op, key=key) + self.assertIn(expected_msg, str(ctx.exception)) + + def test_frozen_immutable(self): + rule = MergeRule(MergeOp.REPLACE) + with self.assertRaises(AttributeError): + rule.op = MergeOp.CONCATENATE + + +class TestConvenienceConstructors(unittest.TestCase): + @parameterized.expand( + [ + ("CONCATENATE", CONCATENATE, MergeOp.CONCATENATE, None), + ("REPLACE", REPLACE, MergeOp.REPLACE, None), + ("MERGE_BY_KEY", merge_by_key("Key"), MergeOp.MERGE_BY_KEY, "Key"), + ] + ) + def test_constructor(self, _name, rule, expected_op, expected_key): + self.assertEqual(rule.op, expected_op) + self.assertEqual(rule.key, expected_key) + + +class TestSchemaKeyFormat(unittest.TestCase): + """Dot-notation schema keys support nested property paths.""" + + @parameterized.expand( + [ + ("top_level", "Architectures"), + ("one_level_nested", "VpcConfig.SecurityGroupIds"), + ("two_levels_nested", "VpcConfig.SubnetConfig.SubnetIds"), + ] + ) + def test_valid_dot_notation_keys(self, _name, key): + """Dot-separated paths are the schema key format — all valid.""" + schema = {key: REPLACE} + # Should not raise — dots are path separators, not errors + self.assertIn(key, schema) + self.assertEqual(schema[key], REPLACE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/translator/input/globals_merge_strategy_architectures.yaml b/tests/translator/input/globals_merge_strategy_architectures.yaml new file mode 100644 index 0000000000..044e5b0284 --- /dev/null +++ b/tests/translator/input/globals_merge_strategy_architectures.yaml @@ -0,0 +1,21 @@ +# Merge strategy translator-level tests. +# Add new test cases here when new rules are added to CUSTOM_STRATEGIES. +Globals: + Function: + Runtime: python3.12 + Handler: app.handler + Architectures: + - x86_64 + +Resources: + FunctionInheritsGlobalArch: + Type: AWS::Serverless::Function + Properties: + CodeUri: s3://bucket/code.zip + + FunctionOverridesArch: + Type: AWS::Serverless::Function + Properties: + CodeUri: s3://bucket/code.zip + Architectures: + - arm64 diff --git a/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json b/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json new file mode 100644 index 0000000000..3c05d65936 --- /dev/null +++ b/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json @@ -0,0 +1,116 @@ +{ + "Resources": { + "FunctionInheritsGlobalArch": { + "Properties": { + "Architectures": [ + "x86_64" + ], + "Code": { + "S3Bucket": "bucket", + "S3Key": "code.zip" + }, + "Handler": "app.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionInheritsGlobalArchRole", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "FunctionInheritsGlobalArchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "FunctionOverridesArch": { + "Properties": { + "Architectures": [ + "arm64" + ], + "Code": { + "S3Bucket": "bucket", + "S3Key": "code.zip" + }, + "Handler": "app.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionOverridesArchRole", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "FunctionOverridesArchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json b/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json new file mode 100644 index 0000000000..045a5db85a --- /dev/null +++ b/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json @@ -0,0 +1,116 @@ +{ + "Resources": { + "FunctionInheritsGlobalArch": { + "Properties": { + "Architectures": [ + "x86_64" + ], + "Code": { + "S3Bucket": "bucket", + "S3Key": "code.zip" + }, + "Handler": "app.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionInheritsGlobalArchRole", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "FunctionInheritsGlobalArchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "FunctionOverridesArch": { + "Properties": { + "Architectures": [ + "arm64" + ], + "Code": { + "S3Bucket": "bucket", + "S3Key": "code.zip" + }, + "Handler": "app.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionOverridesArchRole", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "FunctionOverridesArchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} diff --git a/tests/translator/output/globals_merge_strategy_architectures.json b/tests/translator/output/globals_merge_strategy_architectures.json new file mode 100644 index 0000000000..64ced32e59 --- /dev/null +++ b/tests/translator/output/globals_merge_strategy_architectures.json @@ -0,0 +1,116 @@ +{ + "Resources": { + "FunctionInheritsGlobalArch": { + "Properties": { + "Architectures": [ + "x86_64" + ], + "Code": { + "S3Bucket": "bucket", + "S3Key": "code.zip" + }, + "Handler": "app.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionInheritsGlobalArchRole", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "FunctionInheritsGlobalArchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "FunctionOverridesArch": { + "Properties": { + "Architectures": [ + "arm64" + ], + "Code": { + "S3Bucket": "bucket", + "S3Key": "code.zip" + }, + "Handler": "app.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionOverridesArchRole", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "FunctionOverridesArchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + } + } +} From 052b8619c1be868d9df1e39aed3b60c36844f3b7 Mon Sep 17 00:00:00 2001 From: Vichy Meas Date: Tue, 23 Jun 2026 07:02:31 +0000 Subject: [PATCH 2/6] fix(globals): deduplicate _merge_by_key output when inputs have duplicate keys Guard against duplicate key values in global_list (produces duplicate replacements) and local_list (produces duplicate appends) by checking seen_keys before appending in both passes. Adds regression tests for both cases. --- samtranslator/plugins/globals/globals.py | 9 ++++++--- tests/plugins/globals/test_globals.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index 4f72079fce..9ef6965761 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -515,11 +515,13 @@ def _merge_by_key(self, global_list: list[Any], local_list: list[Any], key: str seen_keys: set[Any] = set() result = [] - # Pass 1: walk globals, replace matched keys with local override + # Pass 1: walk globals, replace matched keys with local override (deduplicate) for item in global_list: if isinstance(item, dict) and key in item and item[key] in local_by_key: - result.append(local_by_key[item[key]]) - seen_keys.add(item[key]) + if item[key] not in seen_keys: + result.append(local_by_key[item[key]]) + seen_keys.add(item[key]) + # else: duplicate global entry — drop it (already replaced once) else: result.append(item) @@ -528,6 +530,7 @@ def _merge_by_key(self, global_list: list[Any], local_list: list[Any], key: str if isinstance(item, dict) and key in item: if item[key] not in seen_keys: result.append(item) + seen_keys.add(item[key]) else: result.append(item) diff --git a/tests/plugins/globals/test_globals.py b/tests/plugins/globals/test_globals.py index 57f5626869..0ae5089fe5 100644 --- a/tests/plugins/globals/test_globals.py +++ b/tests/plugins/globals/test_globals.py @@ -196,6 +196,22 @@ class GlobalPropertiesTestCases: "schema": {"Tags": merge_by_key("Key")}, } + # Regression: duplicate keys in global list must not produce duplicate results + list_with_merge_by_key_deduplicates_global_duplicates = { + "global": {"Tags": [{"Key": "env", "Value": "a"}, {"Key": "env", "Value": "b"}]}, + "local": {"Tags": [{"Key": "env", "Value": "c"}]}, + "expected_output": {"Tags": [{"Key": "env", "Value": "c"}]}, + "schema": {"Tags": merge_by_key("Key")}, + } + + # Regression: duplicate keys in local list must not produce duplicate results + list_with_merge_by_key_deduplicates_local_duplicates = { + "global": {"Tags": [{"Key": "team", "Value": "lambda"}]}, + "local": {"Tags": [{"Key": "env", "Value": "a"}, {"Key": "env", "Value": "b"}]}, + "expected_output": {"Tags": [{"Key": "team", "Value": "lambda"}, {"Key": "env", "Value": "a"}]}, + "schema": {"Tags": merge_by_key("Key")}, + } + # Multiple strategies applied to different properties in one merge. multiple_strategies_applied_per_property = { "global": {"Architectures": ["x86_64"], "Tags": [{"Key": "env", "Value": "dev"}], "Layers": ["arn:layer1"]}, From 69940b4b978b198e2f63ee2326ef320342e39434 Mon Sep 17 00:00:00 2001 From: Vichy Meas Date: Tue, 23 Jun 2026 08:15:50 +0000 Subject: [PATCH 3/6] feat(globals): add REPLACE strategy for CapacityProvider.InstanceRequirements.Architectures Extends CUSTOM_STRATEGIES with a nested dot-path rule proving the framework works at 2+ levels of dict recursion. Adds test cases to both the dedicated merge strategy fixture and the existing capacity_provider_global_with_functions fixture. --- samtranslator/plugins/globals/globals.py | 5 +- ...pacity_provider_global_with_functions.yaml | 13 ++ .../globals_merge_strategy_architectures.yaml | 25 ++++ ...pacity_provider_global_with_functions.json | 70 ++++++++++ .../globals_merge_strategy_architectures.json | 124 ++++++++++++++++++ ...pacity_provider_global_with_functions.json | 70 ++++++++++ .../globals_merge_strategy_architectures.json | 124 ++++++++++++++++++ ...pacity_provider_global_with_functions.json | 70 ++++++++++ .../globals_merge_strategy_architectures.json | 124 ++++++++++++++++++ 9 files changed, 624 insertions(+), 1 deletion(-) diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index 9ef6965761..8f91909543 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -8,7 +8,10 @@ from samtranslator.swagger.swagger import SwaggerEditor # Per-property merge schema. Paths not listed here default to CONCATENATE (today's behavior). -CUSTOM_STRATEGIES: dict[str, MergeRule] = {"Function.Architectures": REPLACE} +CUSTOM_STRATEGIES: dict[str, MergeRule] = { + "Function.Architectures": REPLACE, + "CapacityProvider.InstanceRequirements.Architectures": REPLACE, +} class Globals: diff --git a/tests/translator/input/capacity_provider_global_with_functions.yaml b/tests/translator/input/capacity_provider_global_with_functions.yaml index 55a4999665..0bb15961e0 100644 --- a/tests/translator/input/capacity_provider_global_with_functions.yaml +++ b/tests/translator/input/capacity_provider_global_with_functions.yaml @@ -163,6 +163,19 @@ Resources: MinExecutionEnvironments: 3 # support partial override AutoPublishAlias: Development + # CapacityProvider that overrides global Architectures (tests REPLACE merge strategy) + CpOverridesArchitectures: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - sg-12345678 + SubnetIds: + - subnet-12345678 + InstanceRequirements: + Architectures: + - arm64 + Outputs: CapacityProviderArn: Description: ARN of the created capacity provider diff --git a/tests/translator/input/globals_merge_strategy_architectures.yaml b/tests/translator/input/globals_merge_strategy_architectures.yaml index 044e5b0284..137e491bc0 100644 --- a/tests/translator/input/globals_merge_strategy_architectures.yaml +++ b/tests/translator/input/globals_merge_strategy_architectures.yaml @@ -6,6 +6,10 @@ Globals: Handler: app.handler Architectures: - x86_64 + CapacityProvider: + InstanceRequirements: + Architectures: + - x86_64 Resources: FunctionInheritsGlobalArch: @@ -19,3 +23,24 @@ Resources: CodeUri: s3://bucket/code.zip Architectures: - arm64 + + CpInheritsGlobalArch: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - sg-12345678 + SubnetIds: + - subnet-12345678 + + CpOverridesArch: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SecurityGroupIds: + - sg-12345678 + SubnetIds: + - subnet-12345678 + InstanceRequirements: + Architectures: + - arm64 diff --git a/tests/translator/output/aws-cn/capacity_provider_global_with_functions.json b/tests/translator/output/aws-cn/capacity_provider_global_with_functions.json index cb8ad07016..fb45916d9d 100644 --- a/tests/translator/output/aws-cn/capacity_provider_global_with_functions.json +++ b/tests/translator/output/aws-cn/capacity_provider_global_with_functions.json @@ -92,6 +92,76 @@ } }, "Resources": { + "CpOverridesArchitectures": { + "Properties": { + "CapacityProviderScalingConfig": { + "ScalingMode": "Manual", + "ScalingPolicies": [ + { + "PredefinedMetricType": "LambdaCapacityProviderAverageCPUUtilization", + "TargetValue": { + "Fn::If": [ + "IsProd", + 80.0, + 70.0 + ] + } + } + ] + }, + "InstanceRequirements": { + "Architectures": [ + "arm64" + ] + }, + "KmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/some-kms-key", + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CustomPermissionsCapacityProviderOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "Environment", + "Value": { + "Ref": "Environment" + } + }, + { + "Key": "Team", + "Value": { + "Ref": "Team" + } + }, + { + "Key": "ManagedBy", + "Value": "SAM" + }, + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + { + "Ref": "SecurityGroupId" + }, + "sg-12345678" + ], + "SubnetIds": [ + { + "Ref": "SubnetId" + }, + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, "CustomPermissionsCapacityProviderOperatorRole": { "Properties": { "AssumeRolePolicyDocument": { diff --git a/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json b/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json index 3c05d65936..ff6a38ac02 100644 --- a/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json +++ b/tests/translator/output/aws-cn/globals_merge_strategy_architectures.json @@ -1,5 +1,129 @@ { "Resources": { + "CpInheritsGlobalArch": { + "Properties": { + "InstanceRequirements": { + "Architectures": [ + "x86_64" + ] + }, + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpInheritsGlobalArchOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-12345678" + ], + "SubnetIds": [ + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpInheritsGlobalArchOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "CpOverridesArch": { + "Properties": { + "InstanceRequirements": { + "Architectures": [ + "arm64" + ] + }, + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpOverridesArchOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-12345678" + ], + "SubnetIds": [ + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpOverridesArchOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, "FunctionInheritsGlobalArch": { "Properties": { "Architectures": [ diff --git a/tests/translator/output/aws-us-gov/capacity_provider_global_with_functions.json b/tests/translator/output/aws-us-gov/capacity_provider_global_with_functions.json index 12f0593931..72fd03e6c3 100644 --- a/tests/translator/output/aws-us-gov/capacity_provider_global_with_functions.json +++ b/tests/translator/output/aws-us-gov/capacity_provider_global_with_functions.json @@ -92,6 +92,76 @@ } }, "Resources": { + "CpOverridesArchitectures": { + "Properties": { + "CapacityProviderScalingConfig": { + "ScalingMode": "Manual", + "ScalingPolicies": [ + { + "PredefinedMetricType": "LambdaCapacityProviderAverageCPUUtilization", + "TargetValue": { + "Fn::If": [ + "IsProd", + 80.0, + 70.0 + ] + } + } + ] + }, + "InstanceRequirements": { + "Architectures": [ + "arm64" + ] + }, + "KmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/some-kms-key", + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CustomPermissionsCapacityProviderOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "Environment", + "Value": { + "Ref": "Environment" + } + }, + { + "Key": "Team", + "Value": { + "Ref": "Team" + } + }, + { + "Key": "ManagedBy", + "Value": "SAM" + }, + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + { + "Ref": "SecurityGroupId" + }, + "sg-12345678" + ], + "SubnetIds": [ + { + "Ref": "SubnetId" + }, + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, "CustomPermissionsCapacityProviderOperatorRole": { "Properties": { "AssumeRolePolicyDocument": { diff --git a/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json b/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json index 045a5db85a..a3a4d0b7ab 100644 --- a/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json +++ b/tests/translator/output/aws-us-gov/globals_merge_strategy_architectures.json @@ -1,5 +1,129 @@ { "Resources": { + "CpInheritsGlobalArch": { + "Properties": { + "InstanceRequirements": { + "Architectures": [ + "x86_64" + ] + }, + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpInheritsGlobalArchOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-12345678" + ], + "SubnetIds": [ + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpInheritsGlobalArchOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "CpOverridesArch": { + "Properties": { + "InstanceRequirements": { + "Architectures": [ + "arm64" + ] + }, + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpOverridesArchOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-12345678" + ], + "SubnetIds": [ + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpOverridesArchOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, "FunctionInheritsGlobalArch": { "Properties": { "Architectures": [ diff --git a/tests/translator/output/capacity_provider_global_with_functions.json b/tests/translator/output/capacity_provider_global_with_functions.json index 936e27ca98..03de028bb0 100644 --- a/tests/translator/output/capacity_provider_global_with_functions.json +++ b/tests/translator/output/capacity_provider_global_with_functions.json @@ -92,6 +92,76 @@ } }, "Resources": { + "CpOverridesArchitectures": { + "Properties": { + "CapacityProviderScalingConfig": { + "ScalingMode": "Manual", + "ScalingPolicies": [ + { + "PredefinedMetricType": "LambdaCapacityProviderAverageCPUUtilization", + "TargetValue": { + "Fn::If": [ + "IsProd", + 80.0, + 70.0 + ] + } + } + ] + }, + "InstanceRequirements": { + "Architectures": [ + "arm64" + ] + }, + "KmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/some-kms-key", + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CustomPermissionsCapacityProviderOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "Environment", + "Value": { + "Ref": "Environment" + } + }, + { + "Key": "Team", + "Value": { + "Ref": "Team" + } + }, + { + "Key": "ManagedBy", + "Value": "SAM" + }, + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + { + "Ref": "SecurityGroupId" + }, + "sg-12345678" + ], + "SubnetIds": [ + { + "Ref": "SubnetId" + }, + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, "CustomPermissionsCapacityProviderOperatorRole": { "Properties": { "AssumeRolePolicyDocument": { diff --git a/tests/translator/output/globals_merge_strategy_architectures.json b/tests/translator/output/globals_merge_strategy_architectures.json index 64ced32e59..4b0d18efc0 100644 --- a/tests/translator/output/globals_merge_strategy_architectures.json +++ b/tests/translator/output/globals_merge_strategy_architectures.json @@ -1,5 +1,129 @@ { "Resources": { + "CpInheritsGlobalArch": { + "Properties": { + "InstanceRequirements": { + "Architectures": [ + "x86_64" + ] + }, + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpInheritsGlobalArchOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-12345678" + ], + "SubnetIds": [ + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpInheritsGlobalArchOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "CpOverridesArch": { + "Properties": { + "InstanceRequirements": { + "Architectures": [ + "arm64" + ] + }, + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpOverridesArchOperatorRole", + "Arn" + ] + } + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-12345678" + ], + "SubnetIds": [ + "subnet-12345678" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpOverridesArchOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, "FunctionInheritsGlobalArch": { "Properties": { "Architectures": [ From d663ae0f344dafc34138233d20851749617ca271 Mon Sep 17 00:00:00 2001 From: Vichy Meas Date: Tue, 23 Jun 2026 18:22:45 +0000 Subject: [PATCH 4/6] fix(globals): make _merge_by_key first-wins consistent across both passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit local_by_key dict comprehension kept last entry per key, but Pass 2 kept first non-seen entry. When global had the same key as duplicate locals, Pass 1 used last-wins (via local_by_key) while Pass 2 used first-wins — inconsistent behavior depending on whether global contained the key. Fix: build local_by_key with first-entry-wins (skip already-present keys) so both passes agree. Adds regression test for Case B. --- samtranslator/plugins/globals/globals.py | 6 +++++- tests/plugins/globals/test_globals.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index 8f91909543..c5e5cd2258 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -514,7 +514,11 @@ def _merge_by_key(self, global_list: list[Any], local_list: list[Any], key: str :param key: The dict key to match on :return: Merged list """ - local_by_key = {item[key]: item for item in local_list if isinstance(item, dict) and key in item} + # First-entry-wins: skip keys already seen so both passes use consistent precedence + local_by_key: dict[Any, Any] = {} + for item in local_list: + if isinstance(item, dict) and key in item and item[key] not in local_by_key: + local_by_key[item[key]] = item seen_keys: set[Any] = set() result = [] diff --git a/tests/plugins/globals/test_globals.py b/tests/plugins/globals/test_globals.py index 0ae5089fe5..b9ed26cc4f 100644 --- a/tests/plugins/globals/test_globals.py +++ b/tests/plugins/globals/test_globals.py @@ -212,6 +212,14 @@ class GlobalPropertiesTestCases: "schema": {"Tags": merge_by_key("Key")}, } + # Regression: local duplicates with global override — first-wins must be consistent + list_with_merge_by_key_local_duplicates_with_global_override = { + "global": {"Tags": [{"Key": "env", "Value": "g"}]}, + "local": {"Tags": [{"Key": "env", "Value": "a"}, {"Key": "env", "Value": "b"}]}, + "expected_output": {"Tags": [{"Key": "env", "Value": "a"}]}, + "schema": {"Tags": merge_by_key("Key")}, + } + # Multiple strategies applied to different properties in one merge. multiple_strategies_applied_per_property = { "global": {"Architectures": ["x86_64"], "Tags": [{"Key": "env", "Value": "dev"}], "Layers": ["arn:layer1"]}, From c652d25679554d06361ab20bea326d0f37a8f0b7 Mon Sep 17 00:00:00 2001 From: Vichy Meas Date: Tue, 23 Jun 2026 20:56:43 +0000 Subject: [PATCH 5/6] feat(globals): add REPLACE_KEYS_MERGE_VALUES strategy for dict properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new merge strategy that replaces at the key level (only local's keys survive) but deep-merges values when both global and local share the same key. No parameters needed — existing recursion handles all value types (dicts deep-merge, scalars local-win, lists concatenate). Registered for CapacityProvider.ManagedResourceTags: when local sets Tags, global Propagate is dropped; when both have Tags, values merge. Empty local {} inherits global (falsy guard, matches SAM precedent). --- samtranslator/plugins/globals/globals.py | 21 ++++++++++++++- .../plugins/globals/merge_strategy.py | 4 ++- tests/plugins/globals/test_globals.py | 27 ++++++++++++++++++- tests/plugins/globals/test_merge_strategy.py | 5 ++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index c5e5cd2258..133526c5d7 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -2,7 +2,7 @@ from typing import Any, Union from samtranslator.model.exceptions import ExceptionWithMessage, InvalidResourceAttributeTypeException -from samtranslator.plugins.globals.merge_strategy import REPLACE, MergeOp, MergeRule +from samtranslator.plugins.globals.merge_strategy import REPLACE, REPLACE_KEYS_MERGE_VALUES, MergeOp, MergeRule from samtranslator.public.intrinsics import is_intrinsics from samtranslator.public.sdk.resource import SamResourceType from samtranslator.swagger.swagger import SwaggerEditor @@ -11,6 +11,7 @@ CUSTOM_STRATEGIES: dict[str, MergeRule] = { "Function.Architectures": REPLACE, "CapacityProvider.InstanceRequirements.Architectures": REPLACE, + "CapacityProvider.ManagedResourceTags": REPLACE_KEYS_MERGE_VALUES, } @@ -481,6 +482,11 @@ def _do_merge(self, global_value, local_value, path=""): # type: ignore[no-unty return self._prefer_local(global_value, local_value) # type: ignore[no-untyped-call] if self.TOKEN.DICT == token_global == token_local: + rule = self.schema.get(path) + if rule and rule.op == MergeOp.REPLACE: + return local_value + if rule and rule.op == MergeOp.REPLACE_KEYS_MERGE_VALUES and local_value: + return self._replace_keys_merge_values(global_value, local_value, path) return self._merge_dict(global_value, local_value, path) # type: ignore[no-untyped-call] if self.TOKEN.LIST == token_global == token_local: @@ -543,6 +549,19 @@ def _merge_by_key(self, global_list: list[Any], local_list: list[Any], key: str return result + def _replace_keys_merge_values(self, global_dict: dict[str, Any], local_dict: dict[str, Any], path: str) -> dict[str, Any]: + """ + Only local's key-set survives. Shared keys have their values deep-merged + via existing recursion. Global keys not in local are dropped. + """ + result: dict[str, Any] = {} + for key in local_dict: + if key in global_dict: + result[key] = self._do_merge(global_dict[key], local_dict[key], f"{path}.{key}") + else: + result[key] = local_dict[key] + return result + def _merge_dict(self, global_dict, local_dict, path_prefix=""): # type: ignore[no-untyped-def] """ Merges the two dictionaries together diff --git a/samtranslator/plugins/globals/merge_strategy.py b/samtranslator/plugins/globals/merge_strategy.py index 159b78050b..0d96732eea 100644 --- a/samtranslator/plugins/globals/merge_strategy.py +++ b/samtranslator/plugins/globals/merge_strategy.py @@ -8,6 +8,7 @@ class MergeOp(Enum): CONCATENATE = "concatenate" REPLACE = "replace" MERGE_BY_KEY = "merge_by_key" + REPLACE_KEYS_MERGE_VALUES = "replace_keys_merge_values" @dataclass(frozen=True) @@ -18,13 +19,14 @@ class MergeRule: def __post_init__(self) -> None: if self.op == MergeOp.MERGE_BY_KEY and not self.key: raise ValueError("MERGE_BY_KEY requires a 'key' field") - if self.op != MergeOp.MERGE_BY_KEY and self.key is not None: + if self.op not in (MergeOp.MERGE_BY_KEY,) and self.key is not None: raise ValueError(f"'key' is only valid with MERGE_BY_KEY, not {self.op.value}") # Explicit default; not needed in CUSTOM_STRATEGIES (unlisted paths already concatenate). CONCATENATE = MergeRule(MergeOp.CONCATENATE) REPLACE = MergeRule(MergeOp.REPLACE) +REPLACE_KEYS_MERGE_VALUES = MergeRule(MergeOp.REPLACE_KEYS_MERGE_VALUES) def merge_by_key(key: str) -> MergeRule: diff --git a/tests/plugins/globals/test_globals.py b/tests/plugins/globals/test_globals.py index b9ed26cc4f..48ac068da6 100644 --- a/tests/plugins/globals/test_globals.py +++ b/tests/plugins/globals/test_globals.py @@ -4,7 +4,7 @@ from parameterized import parameterized from samtranslator.model.exceptions import InvalidResourceAttributeTypeException from samtranslator.plugins.globals.globals import GlobalProperties, Globals, InvalidGlobalsSectionException -from samtranslator.plugins.globals.merge_strategy import REPLACE, merge_by_key +from samtranslator.plugins.globals.merge_strategy import REPLACE, REPLACE_KEYS_MERGE_VALUES, merge_by_key class GlobalPropertiesTestCases: @@ -232,6 +232,31 @@ class GlobalPropertiesTestCases: "schema": {"Architectures": REPLACE, "Tags": merge_by_key("Key")}, } + # REPLACE_KEYS_MERGE_VALUES: local's key-set wins; shared keys deep-merge values. + # Combined case: different keys dropped + shared dict deep-merged + shared scalar local-wins + replace_keys_merge_values_complex = { + "global": {"MRT": {"Propagate": True, "Tags": {"team": "plat", "env": "dev"}, "Meta": ["x"]}}, + "local": {"MRT": {"Propagate": False, "Tags": {"env": "prod", "app": "svc"}, "Meta": ["y"]}}, + "expected_output": {"MRT": {"Propagate": False, "Tags": {"team": "plat", "env": "prod", "app": "svc"}, "Meta": ["x", "y"]}}, + "schema": {"MRT": REPLACE_KEYS_MERGE_VALUES}, + } + + # Key-dropping: global-only keys removed; local-only keys kept; shared key deep-merges + replace_keys_merge_values_key_drop = { + "global": {"MRT": {"Propagate": True, "Tags": {"team": "plat"}}}, + "local": {"MRT": {"Tags": {"env": "prod"}}}, + "expected_output": {"MRT": {"Tags": {"team": "plat", "env": "prod"}}}, + "schema": {"MRT": REPLACE_KEYS_MERGE_VALUES}, + } + + # Empty local {} — inherits global (falsy guard, strategy not invoked) + replace_keys_merge_values_empty_local_inherits = { + "global": {"MRT": {"Propagate": True}}, + "local": {"MRT": {}}, + "expected_output": {"MRT": {"Propagate": True}}, + "schema": {"MRT": REPLACE_KEYS_MERGE_VALUES}, + } + class TestGlobalPropertiesMerge(TestCase): # Get all attributes of the test case object which is not a built-in method like __str__ diff --git a/tests/plugins/globals/test_merge_strategy.py b/tests/plugins/globals/test_merge_strategy.py index 182bde2801..80f76da095 100644 --- a/tests/plugins/globals/test_merge_strategy.py +++ b/tests/plugins/globals/test_merge_strategy.py @@ -6,6 +6,7 @@ from samtranslator.plugins.globals.merge_strategy import ( CONCATENATE, REPLACE, + REPLACE_KEYS_MERGE_VALUES, MergeOp, MergeRule, merge_by_key, @@ -18,6 +19,7 @@ class TestMergeOp(unittest.TestCase): ("concatenate", MergeOp.CONCATENATE, "concatenate"), ("replace", MergeOp.REPLACE, "replace"), ("merge_by_key", MergeOp.MERGE_BY_KEY, "merge_by_key"), + ("replace_keys_merge_values", MergeOp.REPLACE_KEYS_MERGE_VALUES, "replace_keys_merge_values"), ] ) def test_enum_values(self, _name, member, expected): @@ -30,6 +32,7 @@ class TestMergeRule(unittest.TestCase): ("replace", MergeOp.REPLACE, None), ("concatenate", MergeOp.CONCATENATE, None), ("merge_by_key", MergeOp.MERGE_BY_KEY, "Key"), + ("replace_keys_merge_values", MergeOp.REPLACE_KEYS_MERGE_VALUES, None), ] ) def test_valid_creation(self, _name, op, key): @@ -42,6 +45,7 @@ def test_valid_creation(self, _name, op, key): ("merge_by_key_no_key", MergeOp.MERGE_BY_KEY, None, "MERGE_BY_KEY requires a 'key' field"), ("replace_with_key", MergeOp.REPLACE, "Bad", "only valid with MERGE_BY_KEY"), ("concatenate_with_key", MergeOp.CONCATENATE, "Bad", "only valid with MERGE_BY_KEY"), + ("replace_keys_merge_values_with_key", MergeOp.REPLACE_KEYS_MERGE_VALUES, "Bad", "only valid with MERGE_BY_KEY"), ] ) def test_invalid_creation_raises(self, _name, op, key, expected_msg): @@ -60,6 +64,7 @@ class TestConvenienceConstructors(unittest.TestCase): [ ("CONCATENATE", CONCATENATE, MergeOp.CONCATENATE, None), ("REPLACE", REPLACE, MergeOp.REPLACE, None), + ("REPLACE_KEYS_MERGE_VALUES", REPLACE_KEYS_MERGE_VALUES, MergeOp.REPLACE_KEYS_MERGE_VALUES, None), ("MERGE_BY_KEY", merge_by_key("Key"), MergeOp.MERGE_BY_KEY, "Key"), ] ) From ade81e2a40d114cbb094936f2ff21f79c94f7df0 Mon Sep 17 00:00:00 2001 From: Vichy Meas Date: Tue, 23 Jun 2026 21:44:21 +0000 Subject: [PATCH 6/6] feat(globals): add REPLACE_KEYS_MERGE_VALUES strategy for ManagedResourceTags - New MergeOp: local key-set wins, shared keys deep-merge values - Intercept DICT+DICT nodes in _do_merge when strategy registered - Add CapacityProvider.ManagedResourceTags to CUSTOM_STRATEGIES - Fix ruff PLC0206 (.items()) and mypy no-untyped-call - Update translator fixtures: CpOverrideStrategyDropsGlobalKey added, CpExplicitTagsConflictWithGlobal removed (no longer an error) --- samtranslator/plugins/globals/globals.py | 12 ++-- tests/plugins/globals/test_globals.py | 4 +- tests/plugins/globals/test_merge_strategy.py | 7 +- ...pacity_provider_managed_resource_tags.yaml | 13 ++++ ...anaged_resource_tags_mutual_exclusion.yaml | 11 ---- ...pacity_provider_managed_resource_tags.json | 66 +++++++++++++++++++ ...pacity_provider_managed_resource_tags.json | 66 +++++++++++++++++++ ...pacity_provider_managed_resource_tags.json | 66 +++++++++++++++++++ ...anaged_resource_tags_mutual_exclusion.json | 6 +- 9 files changed, 229 insertions(+), 22 deletions(-) diff --git a/samtranslator/plugins/globals/globals.py b/samtranslator/plugins/globals/globals.py index 627962d7fe..71eb9f70cd 100644 --- a/samtranslator/plugins/globals/globals.py +++ b/samtranslator/plugins/globals/globals.py @@ -461,7 +461,7 @@ def merge(self, local_properties): # type: ignore[no-untyped-def] """ return self._do_merge(self.global_properties, local_properties, path="") # type: ignore[no-untyped-call] - def _do_merge(self, global_value, local_value, path=""): # type: ignore[no-untyped-def] + def _do_merge(self, global_value, local_value, path=""): # type: ignore[no-untyped-def] # noqa: PLR0911 """ Actually perform the merge operation for the given inputs. This method is used as part of the recursion. Therefore input values can be of any type. So is the output. @@ -550,17 +550,19 @@ def _merge_by_key(self, global_list: list[Any], local_list: list[Any], key: str return result - def _replace_keys_merge_values(self, global_dict: dict[str, Any], local_dict: dict[str, Any], path: str) -> dict[str, Any]: + def _replace_keys_merge_values( + self, global_dict: dict[str, Any], local_dict: dict[str, Any], path: str + ) -> dict[str, Any]: """ Only local's key-set survives. Shared keys have their values deep-merged via existing recursion. Global keys not in local are dropped. """ result: dict[str, Any] = {} - for key in local_dict: + for key, local_val in local_dict.items(): if key in global_dict: - result[key] = self._do_merge(global_dict[key], local_dict[key], f"{path}.{key}") + result[key] = self._do_merge(global_dict[key], local_val, f"{path}.{key}") # type: ignore[no-untyped-call] else: - result[key] = local_dict[key] + result[key] = local_val return result def _merge_dict(self, global_dict, local_dict, path_prefix=""): # type: ignore[no-untyped-def] diff --git a/tests/plugins/globals/test_globals.py b/tests/plugins/globals/test_globals.py index 48ac068da6..a32a0d1b01 100644 --- a/tests/plugins/globals/test_globals.py +++ b/tests/plugins/globals/test_globals.py @@ -237,7 +237,9 @@ class GlobalPropertiesTestCases: replace_keys_merge_values_complex = { "global": {"MRT": {"Propagate": True, "Tags": {"team": "plat", "env": "dev"}, "Meta": ["x"]}}, "local": {"MRT": {"Propagate": False, "Tags": {"env": "prod", "app": "svc"}, "Meta": ["y"]}}, - "expected_output": {"MRT": {"Propagate": False, "Tags": {"team": "plat", "env": "prod", "app": "svc"}, "Meta": ["x", "y"]}}, + "expected_output": { + "MRT": {"Propagate": False, "Tags": {"team": "plat", "env": "prod", "app": "svc"}, "Meta": ["x", "y"]} + }, "schema": {"MRT": REPLACE_KEYS_MERGE_VALUES}, } diff --git a/tests/plugins/globals/test_merge_strategy.py b/tests/plugins/globals/test_merge_strategy.py index 80f76da095..c2b2b06483 100644 --- a/tests/plugins/globals/test_merge_strategy.py +++ b/tests/plugins/globals/test_merge_strategy.py @@ -45,7 +45,12 @@ def test_valid_creation(self, _name, op, key): ("merge_by_key_no_key", MergeOp.MERGE_BY_KEY, None, "MERGE_BY_KEY requires a 'key' field"), ("replace_with_key", MergeOp.REPLACE, "Bad", "only valid with MERGE_BY_KEY"), ("concatenate_with_key", MergeOp.CONCATENATE, "Bad", "only valid with MERGE_BY_KEY"), - ("replace_keys_merge_values_with_key", MergeOp.REPLACE_KEYS_MERGE_VALUES, "Bad", "only valid with MERGE_BY_KEY"), + ( + "replace_keys_merge_values_with_key", + MergeOp.REPLACE_KEYS_MERGE_VALUES, + "Bad", + "only valid with MERGE_BY_KEY", + ), ] ) def test_invalid_creation_raises(self, _name, op, key, expected_msg): diff --git a/tests/translator/input/capacity_provider_managed_resource_tags.yaml b/tests/translator/input/capacity_provider_managed_resource_tags.yaml index c02d0c5146..17c2b3a7b2 100644 --- a/tests/translator/input/capacity_provider_managed_resource_tags.yaml +++ b/tests/translator/input/capacity_provider_managed_resource_tags.yaml @@ -46,3 +46,16 @@ Resources: Tags: Environment: !Ref Environment Team: !Sub '${AWS::StackName}-tooling' + + # REPLACE_KEYS_MERGE_VALUES: local has only Tags → global Propagate key is dropped + CpOverrideStrategyDropsGlobalKey: + Type: AWS::Serverless::CapacityProvider + Properties: + VpcConfig: + SubnetIds: + - subnet-44444444 + SecurityGroupIds: + - sg-44444444 + ManagedResourceTags: + Tags: + App: my-service diff --git a/tests/translator/input/error_capacity_provider_managed_resource_tags_mutual_exclusion.yaml b/tests/translator/input/error_capacity_provider_managed_resource_tags_mutual_exclusion.yaml index 9e08fb4820..59c9f3f72b 100644 --- a/tests/translator/input/error_capacity_provider_managed_resource_tags_mutual_exclusion.yaml +++ b/tests/translator/input/error_capacity_provider_managed_resource_tags_mutual_exclusion.yaml @@ -18,14 +18,3 @@ Resources: Propagate: true Tags: Environment: Production - - CpExplicitTagsConflictWithGlobal: - Type: AWS::Serverless::CapacityProvider - Properties: - VpcConfig: - SubnetIds: - - subnet-33333333 - ManagedResourceTags: - Tags: - Environment: Production - Team: Tooling diff --git a/tests/translator/output/aws-cn/capacity_provider_managed_resource_tags.json b/tests/translator/output/aws-cn/capacity_provider_managed_resource_tags.json index 5cce90880f..ce336beb56 100644 --- a/tests/translator/output/aws-cn/capacity_provider_managed_resource_tags.json +++ b/tests/translator/output/aws-cn/capacity_provider_managed_resource_tags.json @@ -128,6 +128,72 @@ }, "Type": "AWS::IAM::Role" }, + "CpOverrideStrategyDropsGlobalKey": { + "Properties": { + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpOverrideStrategyDropsGlobalKeyOperatorRole", + "Arn" + ] + } + }, + "PropagateTags": { + "ExplicitTags": [ + { + "Key": "App", + "Value": "my-service" + } + ], + "Mode": "Explicit" + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-44444444" + ], + "SubnetIds": [ + "subnet-44444444" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpOverrideStrategyDropsGlobalKeyOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, "CpOverrideWithExplicitTags": { "Properties": { "PermissionsConfig": { diff --git a/tests/translator/output/aws-us-gov/capacity_provider_managed_resource_tags.json b/tests/translator/output/aws-us-gov/capacity_provider_managed_resource_tags.json index 93cbb50bcf..2b77e99479 100644 --- a/tests/translator/output/aws-us-gov/capacity_provider_managed_resource_tags.json +++ b/tests/translator/output/aws-us-gov/capacity_provider_managed_resource_tags.json @@ -128,6 +128,72 @@ }, "Type": "AWS::IAM::Role" }, + "CpOverrideStrategyDropsGlobalKey": { + "Properties": { + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpOverrideStrategyDropsGlobalKeyOperatorRole", + "Arn" + ] + } + }, + "PropagateTags": { + "ExplicitTags": [ + { + "Key": "App", + "Value": "my-service" + } + ], + "Mode": "Explicit" + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-44444444" + ], + "SubnetIds": [ + "subnet-44444444" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpOverrideStrategyDropsGlobalKeyOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, "CpOverrideWithExplicitTags": { "Properties": { "PermissionsConfig": { diff --git a/tests/translator/output/capacity_provider_managed_resource_tags.json b/tests/translator/output/capacity_provider_managed_resource_tags.json index 09af81aac6..c4cbd291b8 100644 --- a/tests/translator/output/capacity_provider_managed_resource_tags.json +++ b/tests/translator/output/capacity_provider_managed_resource_tags.json @@ -128,6 +128,72 @@ }, "Type": "AWS::IAM::Role" }, + "CpOverrideStrategyDropsGlobalKey": { + "Properties": { + "PermissionsConfig": { + "CapacityProviderOperatorRoleArn": { + "Fn::GetAtt": [ + "CpOverrideStrategyDropsGlobalKeyOperatorRole", + "Arn" + ] + } + }, + "PropagateTags": { + "ExplicitTags": [ + { + "Key": "App", + "Value": "my-service" + } + ], + "Mode": "Explicit" + }, + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ], + "VpcConfig": { + "SecurityGroupIds": [ + "sg-44444444" + ], + "SubnetIds": [ + "subnet-44444444" + ] + } + }, + "Type": "AWS::Lambda::CapacityProvider" + }, + "CpOverrideStrategyDropsGlobalKeyOperatorRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, "CpOverrideWithExplicitTags": { "Properties": { "PermissionsConfig": { diff --git a/tests/translator/output/error_capacity_provider_managed_resource_tags_mutual_exclusion.json b/tests/translator/output/error_capacity_provider_managed_resource_tags_mutual_exclusion.json index 9b28acea69..235b125bdd 100644 --- a/tests/translator/output/error_capacity_provider_managed_resource_tags_mutual_exclusion.json +++ b/tests/translator/output/error_capacity_provider_managed_resource_tags_mutual_exclusion.json @@ -1,11 +1,9 @@ { "_autoGeneratedBreakdownErrorMessage": [ "Invalid Serverless Application Specification document. ", - "Number of errors found: 2. ", + "Number of errors found: 1. ", "Resource with id [CpBothSet] is invalid. ", - "Cannot specify 'ManagedResourceTags.Propagate=True' and 'ManagedResourceTags.Tags' together. ", - "Resource with id [CpExplicitTagsConflictWithGlobal] is invalid. ", "Cannot specify 'ManagedResourceTags.Propagate=True' and 'ManagedResourceTags.Tags' together." ], - "errorMessage": "Invalid Serverless Application Specification document. Number of errors found: 2. Resource with id [CpBothSet] is invalid. Cannot specify 'ManagedResourceTags.Propagate=True' and 'ManagedResourceTags.Tags' together. Resource with id [CpExplicitTagsConflictWithGlobal] is invalid. Cannot specify 'ManagedResourceTags.Propagate=True' and 'ManagedResourceTags.Tags' together." + "errorMessage": "Invalid Serverless Application Specification document. Number of errors found: 1. Resource with id [CpBothSet] is invalid. Cannot specify 'ManagedResourceTags.Propagate=True' and 'ManagedResourceTags.Tags' together." }