Skip to content

Commit bc46915

Browse files
committed
fix(package): process AWS::Include before language-extension expansion (#9027)
Closes #9027. Symptom: when a template uses both Transform: AWS::LanguageExtensions and contains Fn::Transform: AWS::Include buried inside an LE function (e.g. Fn::ToJsonString), sam package fails to rewrite the include's Location to an S3 URL. CloudFormation then rejects the deploy with 'The location parameter is not a valid S3 uri.' Root cause: PackageContext._export ran expand_language_extensions BEFORE the artifact exporter walked Fn::Transform nodes. LE functions like Fn::ToJsonString json.dumps() their argument, collapsing the structural Fn::Transform subtree into a JSON-string literal. By the time the exporter ran, the include was no longer a structural dict node and was invisible to the global-transform walker. Fix: process AWS::Include (and any other GLOBAL_TRANSFORM_EXPORTS handler) on the original template BEFORE LE expansion runs, mirroring CloudFormation's own server-side transform ordering — CFN resolves inline Fn::Transform macros before evaluating AWS::LanguageExtensions. Implementation: - Extract Template._export_global_artifacts to a module-level _export_global_artifacts_pass(template_dict, uploader, template_dir). The instance method becomes a one-line delegate so existing callers keep working. - Call _export_global_artifacts_pass on the original template before expand_language_extensions in PackageContext._export (root flow) and in CloudFormationStackResource.do_export (nested-stack child flow). Dynamic-Location AWS::Include inside Fn::ForEach (e.g. Location: ./swagger-${Name}.yaml) is not supported by sam package: a local file path with literal ${...} placeholders does not exist on disk, so is_local_file fails and the existing InvalidLocalPathError fires — which is the right user-facing failure. CloudFormation does not substitute loop variables into Fn::Transform paths server-side either, so the limitation matches CFN's actual capability.
1 parent e8bcff3 commit bc46915

2 files changed

Lines changed: 83 additions & 27 deletions

File tree

samcli/commands/package/package_context.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@
2626
from samcli.commands.package.exceptions import PackageFailedError
2727
from samcli.lib.bootstrap.companion_stack.companion_stack_manager import sync_ecr_stack
2828
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
29-
from samcli.lib.package.artifact_exporter import Template
29+
from samcli.lib.package.artifact_exporter import Template, _export_global_artifacts_pass
3030
from samcli.lib.package.code_signer import CodeSigner
3131
from samcli.lib.package.ecr_uploader import ECRUploader
3232
from samcli.lib.package.language_extensions_packaging import (
3333
generate_and_apply_artifact_mappings,
3434
merge_language_extensions_s3_uris,
3535
)
3636
from samcli.lib.package.s3_uploader import S3Uploader
37-
from samcli.lib.package.uploaders import Uploaders
37+
from samcli.lib.package.uploaders import Destination, Uploaders
3838
from samcli.lib.providers.provider import ResourceIdentifier, Stack, get_resource_full_path_by_id
3939
from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider
4040
from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent
@@ -177,6 +177,23 @@ def _export(self, template_path, use_json):
177177
with open(template_path, "r", encoding="utf-8") as f:
178178
original_template_dict = yaml_parse(f.read())
179179

180+
# Process AWS::Include (and other global Fn::Transform exporters) on the
181+
# original template before language-extension expansion. This mirrors
182+
# CloudFormation's server-side transform ordering and prevents LE
183+
# functions like Fn::ToJsonString from collapsing structural Fn::Transform
184+
# nodes into JSON-string literals before the exporter can reach them.
185+
# See aws/aws-sam-cli#9027.
186+
#
187+
# NOTE: this mutates original_template_dict in place. The mutation must
188+
# happen before expand_language_extensions snapshots the tree, so both
189+
# result.original_template and result.expanded_template see the rewrite.
190+
template_dir = os.path.dirname(os.path.abspath(template_path))
191+
_export_global_artifacts_pass(
192+
original_template_dict,
193+
self.uploaders.get(Destination.S3),
194+
template_dir,
195+
)
196+
180197
# Build combined parameter values for expand_language_extensions
181198
parameter_values = {}
182199
parameter_values.update(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES)

samcli/lib/package/artifact_exporter.py

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import copy
1818
import logging
1919
import os
20-
from typing import Dict, List, Optional, Sequence
20+
from typing import Any, Dict, List, Optional, Sequence, cast
2121

2222
from botocore.utils import set_value_from_jmespath
2323

@@ -119,6 +119,46 @@ def _resolve_nested_stack_parameters(nested_params: Dict, parent_parameter_value
119119
return resolved
120120

121121

122+
def _export_global_artifacts_pass(template_dict: Any, uploader, template_dir: str) -> Any:
123+
"""
124+
Walk template_dict recursively, dispatching dict nodes to handlers
125+
registered in GLOBAL_TRANSFORM_EXPORTS (today: AWS::Include).
126+
127+
Mutates template_dict in place AND returns it for caller convenience.
128+
129+
This is the standalone form of Template._export_global_artifacts —
130+
used by callers that need to run the global-transform export pass
131+
on a template before constructing a Template (e.g., the pre-LE
132+
pass that processes AWS::Include before language-extension
133+
expansion runs).
134+
135+
No-ops on non-dict input (e.g. yaml_parse returning None for empty
136+
template files), so callers can pass the result of yaml_parse
137+
unconditionally.
138+
"""
139+
if not isinstance(template_dict, dict):
140+
return template_dict
141+
142+
specs_by_key: Dict[str, list] = {}
143+
for spec in GLOBAL_TRANSFORM_EXPORTS:
144+
specs_by_key.setdefault(spec.template_key, []).append(spec)
145+
146+
for key, val in template_dict.items():
147+
if key in specs_by_key:
148+
current = val
149+
for spec in specs_by_key[key]:
150+
if spec.discriminator(current):
151+
template_dict[key] = spec.handler(current, uploader, template_dir)
152+
current = template_dict[key]
153+
elif isinstance(val, dict):
154+
_export_global_artifacts_pass(val, uploader, template_dir)
155+
elif isinstance(val, list):
156+
for item in val:
157+
if isinstance(item, dict):
158+
_export_global_artifacts_pass(item, uploader, template_dir)
159+
return template_dict
160+
161+
122162
class CloudFormationStackResource(ResourceZip):
123163
"""
124164
Represents CloudFormation::Stack resource that can refer to a nested
@@ -168,6 +208,18 @@ def do_export(self, resource_id, resource_dict, parent_dir):
168208

169209
child_template_dir = os.path.dirname(abs_template_path)
170210

211+
# Process AWS::Include before LE expansion to mirror CFN's transform
212+
# ordering. See aws/aws-sam-cli#9027.
213+
#
214+
# NOTE: mutates child_template_dict in place. Must run before the
215+
# expand_language_extensions call below so result.original_template
216+
# and result.expanded_template both observe the rewrite.
217+
_export_global_artifacts_pass(
218+
child_template_dict,
219+
self.uploaders.get(ResourceZip.EXPORT_DESTINATION),
220+
child_template_dir,
221+
)
222+
171223
# Merge pseudo-parameters with:
172224
# 1) values propagated from the parent Template (parent stack's own params
173225
# + CLI --parameter-overrides + pseudo-params), used to resolve intrinsics
@@ -403,31 +455,18 @@ def __init__(
403455
self.parameter_values = parameter_values
404456

405457
def _export_global_artifacts(self, template_dict: Dict) -> Dict:
458+
"""See module-level _export_global_artifacts_pass for the canonical
459+
implementation. This wrapper exists so Template.export() doesn't
460+
need to know about uploader / template_dir plumbing.
406461
"""
407-
Template params such as AWS::Include transforms are not specific to
408-
any resource type but contain artifacts that should be exported,
409-
here we iterate through the template dict and dispatch to handlers
410-
declared in GLOBAL_TRANSFORM_EXPORTS.
411-
"""
412-
specs_by_key: Dict[str, list] = {}
413-
for spec in GLOBAL_TRANSFORM_EXPORTS:
414-
specs_by_key.setdefault(spec.template_key, []).append(spec)
415-
416-
for key, val in template_dict.items():
417-
if key in specs_by_key:
418-
for spec in specs_by_key[key]:
419-
if spec.discriminator(val):
420-
template_dict[key] = spec.handler(
421-
val, self.uploaders.get(ResourceZip.EXPORT_DESTINATION), self.template_dir
422-
)
423-
val = template_dict[key] # subsequent specs see the rewrite
424-
elif isinstance(val, dict):
425-
self._export_global_artifacts(val)
426-
elif isinstance(val, list):
427-
for item in val:
428-
if isinstance(item, dict):
429-
self._export_global_artifacts(item)
430-
return template_dict
462+
result = _export_global_artifacts_pass(
463+
template_dict,
464+
self.uploaders.get(ResourceZip.EXPORT_DESTINATION),
465+
self.template_dir,
466+
)
467+
# template_dict is guaranteed to be a dict here, so the pass returns
468+
# the same dict back. cast() narrows the return type for mypy.
469+
return cast(Dict, result)
431470

432471
def _export_metadata(self):
433472
"""

0 commit comments

Comments
 (0)