@@ -1989,6 +1989,215 @@ def test_include_transform_export_handler_non_include_transform(self, is_local_f
19891989 self .s3_uploader_mock .upload_with_dedup .assert_not_called ()
19901990 self .assertEqual (handler_output , {"Name" : "AWS::OtherTransform" , "Parameters" : {"Location" : "foo.yaml" }})
19911991
1992+ def test_le_template_with_top_level_aws_include_merges_location (self ):
1993+ """End-to-end regression: an LE template with a top-level AWS::Include
1994+ in Outputs (outside any Fn::ForEach body) must end up with its
1995+ Location rewritten to the uploader's S3 URL after the
1996+ package_context._export-equivalent flow:
1997+
1998+ _export_global_artifacts_pass(template_dict, ...)
1999+ -> expand_language_extensions(...)
2000+ -> Template(template_dict=expanded).export()
2001+ -> merge_language_extensions_s3_uris(original, exported, dynamic)
2002+
2003+ The pre-LE global-transform pass is what now rewrites AWS::Include
2004+ Locations; the merge pass only handles resource artifact properties
2005+ and Metadata. See aws/aws-sam-cli#9027.
2006+ """
2007+ import copy as _copy
2008+
2009+ from samcli .lib .cfn_language_extensions .sam_integration import expand_language_extensions
2010+ from samcli .lib .intrinsic_resolver .intrinsics_symbol_table import IntrinsicsSymbolTable
2011+ from samcli .lib .package .artifact_exporter import _export_global_artifacts_pass
2012+ from samcli .lib .package .language_extensions_packaging import merge_language_extensions_s3_uris
2013+
2014+ # Predictable upload URL keyed on basename so we can assert on it later.
2015+ # upload_with_dedup is invoked with absolute paths (for AWS::Include) and
2016+ # with absolute paths to temp zips (for ServerlessFunctionResource folder
2017+ # uploads); both forms have a basename we can use.
2018+ self .s3_uploader_mock .upload_with_dedup .side_effect = (
2019+ lambda local_path , ** _ : f"s3://bucket/{ os .path .basename (local_path )} -md5"
2020+ )
2021+
2022+ with tempfile .TemporaryDirectory () as tmpdir :
2023+ # Real files on disk so is_local_file / is_local_folder return True
2024+ # without needing to mock them. extra.yaml is the AWS::Include target;
2025+ # code/ is the CodeUri folder for the (post-expansion) functions.
2026+ extra_yaml_path = os .path .join (tmpdir , "extra.yaml" )
2027+ with open (extra_yaml_path , "w" , encoding = "utf-8" ) as f :
2028+ f .write ("# stub include target\n " )
2029+ code_dir = os .path .join (tmpdir , "code" )
2030+ os .makedirs (code_dir )
2031+ with open (os .path .join (code_dir , "index.py" ), "w" , encoding = "utf-8" ) as f :
2032+ f .write ("def handler(event, context):\n return event\n " )
2033+
2034+ template_dict = {
2035+ "Transform" : "AWS::LanguageExtensions" ,
2036+ "Resources" : {
2037+ "Fn::ForEach::Services" : [
2038+ "Name" ,
2039+ ["A" , "B" ],
2040+ {
2041+ "${Name}Function" : {
2042+ "Type" : "AWS::Serverless::Function" ,
2043+ "Properties" : {
2044+ "CodeUri" : "./code" ,
2045+ "Handler" : "index.handler" ,
2046+ "Runtime" : "python3.11" ,
2047+ },
2048+ }
2049+ },
2050+ ]
2051+ },
2052+ "Outputs" : {
2053+ "Extra" : {
2054+ "Value" : {
2055+ "Fn::Transform" : {
2056+ "Name" : "AWS::Include" ,
2057+ "Parameters" : {"Location" : "./extra.yaml" },
2058+ }
2059+ }
2060+ }
2061+ },
2062+ }
2063+
2064+ # Mirror PackageContext._export's flow: run the pre-LE
2065+ # global-transform pass on the original template before
2066+ # language-extension expansion.
2067+ _export_global_artifacts_pass (template_dict , self .s3_uploader_mock , tmpdir )
2068+
2069+ parameter_values = dict (IntrinsicsSymbolTable .DEFAULT_PSEUDO_PARAM_VALUES )
2070+
2071+ result = expand_language_extensions (template_dict , parameter_values )
2072+ self .assertTrue (result .had_language_extensions )
2073+
2074+ template = Template (
2075+ template_path = "template.yaml" ,
2076+ parent_dir = tmpdir ,
2077+ uploaders = self .uploaders_mock ,
2078+ code_signer = self .code_signer_mock ,
2079+ normalize_template = True ,
2080+ normalize_parameters = True ,
2081+ template_dict = _copy .deepcopy (result .expanded_template ),
2082+ parameter_values = parameter_values ,
2083+ )
2084+ exported_template = template .export ()
2085+
2086+ output = merge_language_extensions_s3_uris (
2087+ result .original_template , exported_template , result .dynamic_artifact_properties
2088+ )
2089+
2090+ # The original Fn::ForEach is preserved (not re-expanded) in the merged output.
2091+ self .assertIn ("Fn::ForEach::Services" , output ["Resources" ])
2092+
2093+ # The top-level AWS::Include Location is rewritten to the uploader's S3 URL.
2094+ out_location = output ["Outputs" ]["Extra" ]["Value" ]["Fn::Transform" ]["Parameters" ]["Location" ]
2095+ self .assertEqual (out_location , "s3://bucket/extra.yaml-md5" )
2096+
2097+ def test_le_template_with_serverless_repo_metadata_merges_license_url (self ):
2098+ """End-to-end regression: an LE template with
2099+ AWS::ServerlessRepo::Application metadata containing local
2100+ LicenseUrl/ReadmeUrl paths must end up with both rewritten to
2101+ S3 URLs after the package_context._export-equivalent flow:
2102+
2103+ expand_language_extensions(...)
2104+ -> Template(template_dict=expanded).export()
2105+ -> merge_language_extensions_s3_uris(original, exported, dynamic)
2106+
2107+ This covers the Metadata merge pass (Task 4) wired into the
2108+ artifact-exporter's export plumbing.
2109+ """
2110+ import copy as _copy
2111+
2112+ from samcli .lib .cfn_language_extensions .sam_integration import expand_language_extensions
2113+ from samcli .lib .intrinsic_resolver .intrinsics_symbol_table import IntrinsicsSymbolTable
2114+ from samcli .lib .package .language_extensions_packaging import merge_language_extensions_s3_uris
2115+
2116+ # SAR exporters extend ResourceZip, whose do_export ultimately
2117+ # routes through upload_local_artifacts -> uploader.upload_with_dedup.
2118+ # Same lambda as Task 7: a predictable URL keyed on basename.
2119+ self .s3_uploader_mock .upload_with_dedup .side_effect = (
2120+ lambda local_path , ** _ : f"s3://bucket/{ os .path .basename (local_path )} -md5"
2121+ )
2122+
2123+ with tempfile .TemporaryDirectory () as tmpdir :
2124+ # Real files on disk for the SAR exporters (LicenseUrl / ReadmeUrl)
2125+ # and for the Fn::ForEach-expanded SAM function's CodeUri.
2126+ license_path = os .path .join (tmpdir , "LICENSE.txt" )
2127+ with open (license_path , "w" , encoding = "utf-8" ) as f :
2128+ f .write ("MIT\n " )
2129+ readme_path = os .path .join (tmpdir , "README.md" )
2130+ with open (readme_path , "w" , encoding = "utf-8" ) as f :
2131+ f .write ("# MyApp\n " )
2132+ code_dir = os .path .join (tmpdir , "code" )
2133+ os .makedirs (code_dir )
2134+ with open (os .path .join (code_dir , "index.py" ), "w" , encoding = "utf-8" ) as f :
2135+ f .write ("def handler(event, context):\n return event\n " )
2136+
2137+ template_dict = {
2138+ "Transform" : "AWS::LanguageExtensions" ,
2139+ "Metadata" : {
2140+ "AWS::ServerlessRepo::Application" : {
2141+ "Name" : "MyApp" ,
2142+ "Description" : "Test app" ,
2143+ "Author" : "Test" ,
2144+ "SemanticVersion" : "1.0.0" ,
2145+ "LicenseUrl" : "./LICENSE.txt" ,
2146+ "ReadmeUrl" : "./README.md" ,
2147+ }
2148+ },
2149+ "Resources" : {
2150+ "Fn::ForEach::Services" : [
2151+ "Name" ,
2152+ ["A" ],
2153+ {
2154+ "${Name}Function" : {
2155+ "Type" : "AWS::Serverless::Function" ,
2156+ "Properties" : {
2157+ "CodeUri" : "./code" ,
2158+ "Handler" : "index.handler" ,
2159+ "Runtime" : "python3.11" ,
2160+ },
2161+ }
2162+ },
2163+ ]
2164+ },
2165+ }
2166+
2167+ parameter_values = dict (IntrinsicsSymbolTable .DEFAULT_PSEUDO_PARAM_VALUES )
2168+
2169+ result = expand_language_extensions (template_dict , parameter_values )
2170+ self .assertTrue (result .had_language_extensions )
2171+
2172+ template = Template (
2173+ template_path = "template.yaml" ,
2174+ parent_dir = tmpdir ,
2175+ uploaders = self .uploaders_mock ,
2176+ code_signer = self .code_signer_mock ,
2177+ normalize_template = True ,
2178+ normalize_parameters = True ,
2179+ template_dict = _copy .deepcopy (result .expanded_template ),
2180+ parameter_values = parameter_values ,
2181+ )
2182+ exported_template = template .export ()
2183+
2184+ output = merge_language_extensions_s3_uris (
2185+ result .original_template , exported_template , result .dynamic_artifact_properties
2186+ )
2187+
2188+ # SAR LicenseUrl and ReadmeUrl must both be rewritten to S3 URLs.
2189+ sar = output ["Metadata" ]["AWS::ServerlessRepo::Application" ]
2190+ self .assertTrue (sar ["LicenseUrl" ].startswith ("s3://" ), f"LicenseUrl: { sar ['LicenseUrl' ]!r} " )
2191+ self .assertTrue (sar ["ReadmeUrl" ].startswith ("s3://" ), f"ReadmeUrl: { sar ['ReadmeUrl' ]!r} " )
2192+ # The basenames identify which file was uploaded to which property,
2193+ # confirming the SAR exporter fired once for License and once for Readme.
2194+ self .assertIn ("LICENSE.txt" , sar ["LicenseUrl" ])
2195+ self .assertIn ("README.md" , sar ["ReadmeUrl" ])
2196+ # Unrelated SAR metadata is preserved untouched.
2197+ self .assertEqual (sar ["Name" ], "MyApp" )
2198+ self .assertEqual (sar ["SemanticVersion" ], "1.0.0" )
2199+ # The original Fn::ForEach is preserved (not re-expanded) in the merged output.
2200+ self .assertIn ("Fn::ForEach::Services" , output ["Resources" ])
19922201
19932202 def test_template_export_path_be_folder (self ):
19942203 template_path = "/path/foo"
@@ -2801,6 +3010,83 @@ def test_child_template_receives_parent_parameters(self):
28013010 shutil .rmtree (tmpdir , ignore_errors = True )
28023011
28033012
3013+ class TestCloudFormationStackResourceBuriedAWSInclude (unittest .TestCase ):
3014+ """Nested-stack child template containing AWS::Include buried inside an
3015+ LE function (e.g. Fn::ToJsonString) must have the include's Location
3016+ rewritten by the pre-LE global-transform pass in
3017+ CloudFormationStackResource.do_export, before LE expansion would have
3018+ collapsed the structural Fn::Transform into a JSON-string literal.
3019+
3020+ Regression coverage for https://github.com/aws/aws-sam-cli/issues/9027.
3021+ Root-flow coverage lives in tests/unit/commands/package/
3022+ test_package_context.py.
3023+ """
3024+
3025+ def test_buried_aws_include_in_le_child_is_rewritten (self ):
3026+ uploaders_mock = Mock ()
3027+ s3_uploader_mock = Mock ()
3028+ s3_uploader_mock .upload_with_dedup .side_effect = (
3029+ lambda local_path , ** _ : f"s3://bucket/{ os .path .basename (local_path )} -md5"
3030+ )
3031+ # Template upload (the child template itself) — give it a deterministic URL.
3032+ s3_uploader_mock .upload .return_value = "s3://bucket/child-template-md5"
3033+ s3_uploader_mock .to_path_style_s3_url .return_value = "https://s3.amazonaws.com/bucket/child-template-md5"
3034+ uploaders_mock .get .return_value = s3_uploader_mock
3035+
3036+ code_signer_mock = Mock ()
3037+ code_signer_mock .should_sign_package .return_value = False
3038+
3039+ stack_resource = CloudFormationStackResource (uploaders_mock , code_signer_mock )
3040+
3041+ # Child template mirrors the issue #9027 reporter's shape:
3042+ # Fn::ToJsonString over Fn::Transform: AWS::Include, buried inside
3043+ # AWS::SSM::Parameter.Properties.Value. The whole thing wrapped in
3044+ # Transform: AWS::LanguageExtensions so the LE branch is taken.
3045+ child_template = {
3046+ "AWSTemplateFormatVersion" : "2010-09-09" ,
3047+ "Transform" : "AWS::LanguageExtensions" ,
3048+ "Resources" : {
3049+ "Parameter" : {
3050+ "Type" : "AWS::SSM::Parameter" ,
3051+ "Properties" : {
3052+ "Type" : "String" ,
3053+ "DataType" : "text" ,
3054+ "Value" : {
3055+ "Fn::ToJsonString" : {
3056+ "Fn::Transform" : {
3057+ "Name" : "AWS::Include" ,
3058+ "Parameters" : {"Location" : "export-events.json" },
3059+ }
3060+ }
3061+ },
3062+ },
3063+ }
3064+ },
3065+ }
3066+
3067+ tmpdir = tempfile .mkdtemp ()
3068+ try :
3069+ child_path = os .path .join (tmpdir , "child.yaml" )
3070+ include_path = os .path .join (tmpdir , "export-events.json" )
3071+ from samcli .yamlhelper import yaml_dump
3072+
3073+ with open (child_path , "w" , encoding = "utf-8" ) as f :
3074+ f .write (yaml_dump (child_template ))
3075+ with open (include_path , "w" , encoding = "utf-8" ) as f :
3076+ f .write ('[{"event": "demo"}]' )
3077+
3078+ resource_dict = {"TemplateURL" : child_path }
3079+ stack_resource .do_export ("ChildStack" , resource_dict , tmpdir )
3080+
3081+ # Walk every upload_with_dedup call to confirm the include was uploaded.
3082+ uploaded_files = [call_args [0 ][0 ] for call_args in s3_uploader_mock .upload_with_dedup .call_args_list ]
3083+ self .assertIn (include_path , uploaded_files )
3084+ finally :
3085+ import shutil
3086+
3087+ shutil .rmtree (tmpdir , ignore_errors = True )
3088+
3089+
28043090class TestCloudFormationStackResourceExpansionErrorHandling (unittest .TestCase ):
28053091 """Error-path behavior for language-extensions expansion in do_export.
28063092
0 commit comments