|
36 | 36 | matches the user-visible behavior of ``sam build`` on such a template |
37 | 37 | (the LE construct is preserved verbatim for CloudFormation server-side |
38 | 38 | expansion). |
| 39 | +- When ``language_extensions=True`` and the template uses LE: the SAM |
| 40 | + transform is *also* skipped, and the build / package outputs preserve |
| 41 | + ``Fn::ForEach::*`` with merged artifact values inside the body. This mirrors |
| 42 | + what real ``sam build`` and ``sam package`` write to disk (PR #8637): the |
| 43 | + SAM transform runs server-side at deploy time, so the build/package |
| 44 | + artifacts keep ``AWS::Serverless::Function`` (with ``CodeUri``) — they |
| 45 | + are not pre-transformed to ``AWS::Lambda::Function`` (with ``Code``). |
| 46 | + The merge itself delegates to |
| 47 | + ``BuildContext._get_template_for_output`` (build) and |
| 48 | + ``merge_language_extensions_s3_uris`` (package), invoked via lightweight |
| 49 | + shims so the harness does not duplicate the merge logic. |
39 | 50 |
|
40 | 51 | Known limitations (deferred to follow-up PRs / corpus growth): |
41 | 52 |
|
|
66 | 77 |
|
67 | 78 | import copy |
68 | 79 | import hashlib |
| 80 | +import types |
69 | 81 | from pathlib import Path |
70 | 82 | from typing import Any, Dict, List, Optional, Tuple |
71 | 83 |
|
|
74 | 86 | from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions |
75 | 87 | from samcli.lib.cfn_language_extensions.utils import is_foreach_key |
76 | 88 | from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable |
| 89 | +from samcli.lib.providers.provider import get_full_path |
77 | 90 | from samcli.lib.utils.resources import ( |
78 | 91 | RESOURCES_WITH_IMAGE_COMPONENT, |
79 | 92 | RESOURCES_WITH_LOCAL_PATHS, |
@@ -284,24 +297,112 @@ def _run_sam_transform(template: Dict[str, Any], parameter_values: Dict[str, Any |
284 | 297 | return translated |
285 | 298 |
|
286 | 299 |
|
| 300 | +def _build_artifacts_map(template: Dict[str, Any]) -> Dict[str, str]: |
| 301 | + """Build the ``artifacts: Dict[str, str]`` map BuildContext expects. |
| 302 | +
|
| 303 | + Keys are ``<stack_path>/<resource_id>`` full paths (with empty stack_path |
| 304 | + this collapses to just ``<resource_id>``); values are the placeholder |
| 305 | + string standing in for an on-disk built artifact location. We only |
| 306 | + populate entries for resources that actually carry a packageable artifact |
| 307 | + property — this matches BuildContext's check for whether to merge an |
| 308 | + expanded resource back into the original ForEach body. |
| 309 | + """ |
| 310 | + artifacts: Dict[str, str] = {} |
| 311 | + seen: set = set() |
| 312 | + for resource_id, _prop_path, _resource in _walk_artifact_properties(template): |
| 313 | + if resource_id in seen: |
| 314 | + continue |
| 315 | + seen.add(resource_id) |
| 316 | + artifacts[get_full_path("", resource_id)] = BUILT_ARTIFACT_PLACEHOLDER |
| 317 | + return artifacts |
| 318 | + |
| 319 | + |
| 320 | +def _merge_back_into_original( |
| 321 | + original_template: Dict[str, Any], |
| 322 | + modified_template: Dict[str, Any], |
| 323 | + artifacts: Dict[str, str], |
| 324 | +) -> Dict[str, Any]: |
| 325 | + """Call ``BuildContext._get_template_for_output`` without instantiating a |
| 326 | + full BuildContext. |
| 327 | +
|
| 328 | + The method only invokes ``self.*`` for *other* methods on the same class |
| 329 | + (audited in PR 1). The ``stack`` parameter only reads three attributes: |
| 330 | + ``original_template_dict``, ``parameters``, ``stack_path``. So we use a |
| 331 | + ``BuildContext.__new__`` shell as ``self`` and a SimpleNamespace as the |
| 332 | + stack. No instance state is touched. |
| 333 | + """ |
| 334 | + # Lazy import: keep the build_context imports out of the harness |
| 335 | + # module-load path (it pulls in click, telemetry, etc.). |
| 336 | + from samcli.commands.build.build_context import BuildContext |
| 337 | + |
| 338 | + stack = types.SimpleNamespace( |
| 339 | + original_template_dict=original_template, |
| 340 | + parameters={}, |
| 341 | + stack_path="", |
| 342 | + ) |
| 343 | + shell = BuildContext.__new__(BuildContext) |
| 344 | + # mypy: SimpleNamespace is structurally compatible with the three Stack |
| 345 | + # attributes _get_template_for_output reads (audited above), but it isn't |
| 346 | + # an actual Stack instance so the static type check rejects it. |
| 347 | + return BuildContext._get_template_for_output( |
| 348 | + shell, |
| 349 | + stack=stack, # type: ignore[arg-type] |
| 350 | + modified_template=modified_template, |
| 351 | + artifacts=artifacts, |
| 352 | + ) |
| 353 | + |
| 354 | + |
287 | 355 | def run_build_pipeline(template_path: Path, language_extensions: bool) -> Dict[str, Any]: |
288 | 356 | """In-process equivalent of `sam build` for one template. |
289 | 357 |
|
290 | | - 1. Load the template. |
291 | | - 2. Run LE expansion (no-op if disabled or template has no LE transform). |
292 | | - 3. Run the SAM transform on the expanded template. |
293 | | - 4. Replace local artifact paths with BUILT_ARTIFACT_PLACEHOLDER. |
| 358 | + For non-LE templates (and LE-disabled): run SAM transform on the loaded |
| 359 | + template and rewrite local artifact paths to BUILT_ARTIFACT_PLACEHOLDER. |
| 360 | +
|
| 361 | + For templates that use LanguageExtensions and have it enabled: |
| 362 | + 1. LE-expand to get a flat resource list. This is the modified-template |
| 363 | + lookup source for the merge. |
| 364 | + 2. Skip the SAM transform on this LE branch. Real ``sam build`` does |
| 365 | + the same: the transform runs server-side at deploy time so the |
| 366 | + ``.aws-sam/build/template.yaml`` keeps ``AWS::Serverless::Function`` |
| 367 | + (and its ``CodeUri``). Running the SAM transform here would also |
| 368 | + break ``BuildContext._update_original_template_paths``, which keys |
| 369 | + its packageable-property lookup off the *original* ForEach body's |
| 370 | + resource type — a transformed modified template would carry |
| 371 | + ``Code`` while the original body still has ``CodeUri``. |
| 372 | + 3. Build the ``artifacts`` map from the LE-expanded resource ids. |
| 373 | + 4. Replace local artifact paths in the LE-expanded copy with |
| 374 | + BUILT_ARTIFACT_PLACEHOLDER. |
| 375 | + 5. Call ``BuildContext._get_template_for_output`` with the original |
| 376 | + (still ForEach-shaped) template and the modified expanded copy. |
| 377 | + This is the same code path real ``sam build`` uses to produce |
| 378 | + ``.aws-sam/build/template.yaml`` and merges the placeholder values |
| 379 | + back into the ForEach body. |
294 | 380 | """ |
295 | 381 | template = _load_template(template_path) |
296 | 382 |
|
297 | 383 | parameter_values = dict(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES) |
298 | 384 |
|
299 | 385 | le_result = expand_language_extensions(template, parameter_values=parameter_values, enabled=language_extensions) |
300 | | - expanded = copy.deepcopy(le_result.expanded_template) |
301 | 386 |
|
302 | | - transformed = _run_sam_transform(expanded, parameter_values) |
303 | | - _replace_local_artifact_paths(transformed) |
304 | | - return transformed |
| 387 | + if not le_result.had_language_extensions: |
| 388 | + # No LE (either disabled or template doesn't use it): the transformed |
| 389 | + # template is what gets written to disk. Rewrite paths in place. |
| 390 | + expanded = copy.deepcopy(le_result.expanded_template) |
| 391 | + transformed = _run_sam_transform(expanded, parameter_values) |
| 392 | + _replace_local_artifact_paths(transformed) |
| 393 | + return transformed |
| 394 | + |
| 395 | + # LE path: do NOT run SAM transform — see docstring above. Build the |
| 396 | + # artifacts map from the LE-expanded ids, rewrite paths to the |
| 397 | + # placeholder, then delegate the merge to BuildContext. |
| 398 | + expanded = copy.deepcopy(le_result.expanded_template) |
| 399 | + artifacts = _build_artifacts_map(expanded) |
| 400 | + _replace_local_artifact_paths(expanded) |
| 401 | + return _merge_back_into_original( |
| 402 | + original_template=le_result.original_template, |
| 403 | + modified_template=expanded, |
| 404 | + artifacts=artifacts, |
| 405 | + ) |
305 | 406 |
|
306 | 407 |
|
307 | 408 | class GoldenS3Uploader: |
@@ -348,24 +449,50 @@ def run_package_pipeline( |
348 | 449 | ) -> Dict[str, Any]: |
349 | 450 | """In-process equivalent of `sam package` for one template + build output. |
350 | 451 |
|
351 | | - 1. Walk packageable artifact properties on the build output and replace |
352 | | - each local path with an s3://golden-bucket/<sha256> URI. |
353 | | - 2. Run _export_global_artifacts_pass on the rewritten template to |
354 | | - handle any AWS::Include structural nodes. |
355 | | - 3. Return the final dict. |
| 452 | + For non-LE inputs: walk packageable artifact properties on the build |
| 453 | + output and replace each local path with an s3://golden-bucket/<sha256> |
| 454 | + URI; then run ``_export_global_artifacts_pass`` for AWS::Include. |
| 455 | +
|
| 456 | + For LE inputs (the build output preserves ``Fn::ForEach::*``): mirror |
| 457 | + ``PackageContext._export_with_language_extensions`` — |
| 458 | + 1. Run ``_export_global_artifacts_pass`` on a copy of the build_output |
| 459 | + so AWS::Include nodes resolve before LE expansion collapses |
| 460 | + structural Fn::Transform subtrees into JSON-string literals. |
| 461 | + 2. LE-expand that copy to get a flat resource view. |
| 462 | + 3. Walk the expanded resources, rewriting each artifact property to an |
| 463 | + s3://golden-bucket/... URI (or {S3Bucket,S3Key} dict for Lambdas). |
| 464 | + 4. Call ``merge_language_extensions_s3_uris(original=build_output, |
| 465 | + exported=expanded-with-s3-uris)`` to copy the URIs back into the |
| 466 | + original ForEach body. The result preserves ``Fn::ForEach::*``. |
356 | 467 | """ |
357 | 468 | from samcli.lib.package.artifact_exporter import _export_global_artifacts_pass |
| 469 | + from samcli.lib.package.language_extensions_packaging import merge_language_extensions_s3_uris |
358 | 470 |
|
359 | 471 | template_dir = str(template_path.parent) |
360 | 472 | uploader = GoldenS3Uploader(template_dir) |
361 | 473 |
|
362 | | - # Walk build_output's artifact properties and rewrite local paths. |
363 | 474 | pkg = copy.deepcopy(build_output) |
364 | | - _rewrite_artifacts_to_s3(pkg, uploader, template_dir) |
365 | 475 |
|
366 | | - # AWS::Include pass on the rewritten template. |
| 476 | + # AWS::Include pass before LE expansion (mirrors PackageContext._export_with_language_extensions). |
367 | 477 | _export_global_artifacts_pass(pkg, uploader, template_dir) |
368 | | - return pkg |
| 478 | + |
| 479 | + le_result = expand_language_extensions(pkg, parameter_values=None, enabled=True) |
| 480 | + if not le_result.had_language_extensions: |
| 481 | + # Plain template (or LE disabled at build time): the build output is |
| 482 | + # already a flat resource list. Rewrite artifacts in place and return. |
| 483 | + _rewrite_artifacts_to_s3(pkg, uploader, template_dir) |
| 484 | + return pkg |
| 485 | + |
| 486 | + # LE path: rewrite artifacts on the expanded copy, then merge back into the |
| 487 | + # original (build_output) which still has Fn::ForEach::* intact. |
| 488 | + expanded = copy.deepcopy(le_result.expanded_template) |
| 489 | + _rewrite_artifacts_to_s3(expanded, uploader, template_dir) |
| 490 | + |
| 491 | + return merge_language_extensions_s3_uris( |
| 492 | + original_template=pkg, |
| 493 | + exported_template=expanded, |
| 494 | + dynamic_properties=le_result.dynamic_artifact_properties, |
| 495 | + ) |
369 | 496 |
|
370 | 497 |
|
371 | 498 | def _rewrite_artifacts_to_s3(template: Dict[str, Any], uploader, template_dir: str) -> None: |
|
0 commit comments