Skip to content

Commit 1cf770e

Browse files
committed
feat(sync): add CloudFormation Language Extensions support for sam sync
Extend sam sync to fully support templates with AWS::LanguageExtensions and Fn::ForEach. Previously, sync only had guard-level support that skipped ForEach keys to avoid crashes. Changes: - Sanitize artifact properties inside Fn::ForEach body resources during template comparison so code-only changes don't trigger unnecessary infra syncs. Handles all code-syncable resource types: functions (zip + image), layers, APIs, HTTP APIs, state machines, and nested stacks. - Detect code changes in ForEach-generated resources by expanding both the current and deployed templates, then comparing expanded resources. Changed resources are queued for code sync instead of triggering a full CloudFormation deployment. - Code sync (sam sync --code) and watch mode (sam sync --watch) already work via SamLocalStackProvider.get_stacks() LE expansion. All sync flows (ADL, Image, API, Layer, StateMachine) work with expanded ForEach resource identifiers via CFN physical ID mapping.
1 parent c4640d7 commit 1cf770e

3 files changed

Lines changed: 709 additions & 2 deletions

File tree

docs/cfn-language-extensions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ SAM CLI now supports templates that use the `AWS::LanguageExtensions` transform,
44

55
## How it works
66

7-
When SAM CLI detects `AWS::LanguageExtensions` in a template's `Transform` section, it expands language extension constructs locally before running SAM transforms. This enables `sam build`, `sam package`, `sam deploy`, `sam validate`, `sam local invoke`, and `sam local start-api` to work with templates that use these constructs.
7+
When SAM CLI detects `AWS::LanguageExtensions` in a template's `Transform` section, it expands language extension constructs locally before running SAM transforms. This enables `sam build`, `sam package`, `sam deploy`, `sam sync`, `sam validate`, `sam local invoke`, and `sam local start-api` to work with templates that use these constructs.
88

99
The expansion happens in two phases:
1010

samcli/lib/sync/infra_sync_executor.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import re
88
from datetime import datetime, timezone
99
from pathlib import Path
10-
from typing import TYPE_CHECKING, Dict, List, Optional, Set, cast
10+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, cast
1111
from uuid import uuid4
1212

1313
from boto3 import Session
@@ -348,6 +348,11 @@ def _auto_skip_infra_sync(
348348
):
349349
return False
350350

351+
# Detect code changes in ForEach-generated resources by comparing expanded templates
352+
self._detect_foreach_code_changes(
353+
current_template, last_deployed_template_dict, nested_prefix
354+
)
355+
351356
LOG.debug("There are no changes from the previously deployed template for %s", packaged_template_path)
352357
return True
353358

@@ -399,6 +404,8 @@ def _sanitize_template(
399404

400405
for resource_logical_id in resources:
401406
if is_foreach_key(resource_logical_id):
407+
foreach_value = resources.get(resource_logical_id)
408+
self._sanitize_foreach_body(foreach_value, linked_resources, built_template_dict)
402409
continue
403410

404411
resource_dict = resources.get(resource_logical_id, {})
@@ -497,6 +504,130 @@ def _remove_resource_field(
497504

498505
return processed_logical_id
499506

507+
def _sanitize_foreach_body(
508+
self,
509+
foreach_value: Any,
510+
linked_resources: Optional[Set[str]] = None,
511+
built_template_dict: Optional[Dict] = None,
512+
) -> None:
513+
"""
514+
Sanitize code-syncable properties inside a Fn::ForEach body.
515+
516+
Recurses into the body dict (element [2] of the ForEach list) and removes
517+
artifact properties (CodeUri, ImageUri, DefinitionUri, ContentUri, etc.)
518+
from each resource definition, using the same removal maps as regular resources.
519+
Also handles nested Fn::ForEach blocks by recursing further.
520+
521+
Parameters
522+
----------
523+
foreach_value : Any
524+
The Fn::ForEach value (should be a list with 3 elements: [loop_var, collection, body])
525+
linked_resources : Optional[Set[str]]
526+
The corresponding resources in the other template that got processed
527+
built_template_dict : Optional[Dict]
528+
The built template dict (not used for ForEach body since paths are dynamic)
529+
"""
530+
if not isinstance(foreach_value, list) or len(foreach_value) < 3:
531+
return
532+
533+
body = foreach_value[2]
534+
if not isinstance(body, dict):
535+
return
536+
537+
for key, resource_def in body.items():
538+
if is_foreach_key(key):
539+
self._sanitize_foreach_body(resource_def, linked_resources, built_template_dict)
540+
continue
541+
542+
if not isinstance(resource_def, dict):
543+
continue
544+
545+
resource_type = resource_def.get("Type")
546+
if not resource_type:
547+
continue
548+
549+
if resource_type in CODE_SYNCABLE_RESOURCES or resource_type in SYNCABLE_STACK_RESOURCES:
550+
# For ForEach body resources, we always sanitize because the artifact paths
551+
# are dynamic (contain loop variables or Fn::FindInMap after packaging) and
552+
# will always differ between packaged and deployed templates.
553+
# Pass the key as a linked resource so _remove_resource_field unconditionally removes fields.
554+
self._remove_resource_field(key, resource_type, resource_def, {key}, None)
555+
556+
# Remove SamResourceId metadata
557+
resource_def.get("Metadata", {}).pop("SamResourceId", None)
558+
if not resource_def.get("Metadata"):
559+
resource_def.pop("Metadata", None)
560+
561+
def _detect_foreach_code_changes(
562+
self,
563+
current_template: Dict,
564+
last_deployed_template: Dict,
565+
nested_prefix: Optional[str] = None,
566+
) -> None:
567+
"""
568+
Detect code changes in ForEach-generated resources by expanding both the current
569+
and last deployed templates, then comparing the expanded resources.
570+
571+
Only processes resources that were generated from Fn::ForEach blocks (i.e., resources
572+
present in the expanded template but not in the original template as regular resources).
573+
574+
Parameters
575+
----------
576+
current_template : Dict
577+
The current packaged template (with Fn::ForEach intact)
578+
last_deployed_template : Dict
579+
The last deployed template (with Fn::ForEach intact)
580+
nested_prefix : Optional[str]
581+
The nested stack prefix for resource identifiers
582+
"""
583+
from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension
584+
585+
if not check_using_language_extension(current_template):
586+
return
587+
588+
from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions
589+
590+
try:
591+
current_expanded = expand_language_extensions(current_template).expanded_template
592+
deployed_expanded = expand_language_extensions(last_deployed_template).expanded_template
593+
except Exception:
594+
LOG.debug("Failed to expand language extensions for code change detection, skipping")
595+
return
596+
597+
current_resources = current_expanded.get("Resources", {})
598+
deployed_resources = deployed_expanded.get("Resources", {})
599+
600+
# Only check resources that exist in the expanded template — these include
601+
# both regular resources and ForEach-generated ones. The regular resources
602+
# were already checked in the main loop, so we skip them by checking if
603+
# the resource key exists as a non-ForEach key in the original template.
604+
original_regular_keys = {
605+
k for k in current_template.get("Resources", {}) if not is_foreach_key(k)
606+
}
607+
608+
for resource_id, resource_dict in current_resources.items():
609+
if resource_id in original_regular_keys:
610+
continue # Already handled in the main loop
611+
612+
resource_type = resource_dict.get("Type")
613+
if resource_type not in CODE_SYNCABLE_RESOURCES:
614+
continue
615+
616+
last_resource_dict = deployed_resources.get(resource_id, {})
617+
resolved_id = nested_prefix + resource_id if nested_prefix else resource_id
618+
619+
if resource_type == AWS_LAMBDA_FUNCTION:
620+
if resource_dict.get("Properties", {}).get("Code") != last_resource_dict.get(
621+
"Properties", {}
622+
).get("Code"):
623+
self._code_sync_resources.add(ResourceIdentifier(resolved_id))
624+
else:
625+
for field in GENERAL_REMOVAL_MAP.get(resource_type, []):
626+
if resource_dict.get("Properties", {}).get(field) != last_resource_dict.get(
627+
"Properties", {}
628+
).get(field):
629+
self._code_sync_resources.add(ResourceIdentifier(resolved_id))
630+
500631
def get_template(self, template_path: str) -> Optional[Dict]:
501632
"""
502633
Returns the template dict based on local or remote read logic

0 commit comments

Comments
 (0)