Skip to content

feat: Add CloudFormation Language Extensions support (Fn::ForEach)#8637

Open
bnusunny wants to merge 6 commits intodevelopfrom
feat-language-extension
Open

feat: Add CloudFormation Language Extensions support (Fn::ForEach)#8637
bnusunny wants to merge 6 commits intodevelopfrom
feat-language-extension

Conversation

@bnusunny
Copy link
Contributor

@bnusunny bnusunny commented Feb 9, 2026

Description

This PR adds support for CloudFormation Language Extensions in SAM CLI, addressing GitHub issue #5647.

Features

  • Fn::ForEach - Iterate over collections to generate resources
  • Fn::Length - Get the length of an array
  • Fn::ToJsonString - Convert objects to JSON strings
  • Fn::FindInMap with DefaultValue - Map lookups with fallback values
  • Conditional DeletionPolicy/UpdateReplacePolicy - Use intrinsic functions like Fn::If in resource policies

Key Design Decisions

  1. In-Memory Expansion Only - Templates are expanded in memory for SAM CLI operations, but the original unexpanded template is preserved for CloudFormation deployment
  2. Dynamic Artifact Properties via Mappings - Fn::ForEach blocks with dynamic artifact properties (e.g., CodeUri: ./src/${Name}) are supported via a Mappings transformation
  3. Locally Resolvable Collections Only - Fn::ForEach collections must be resolvable locally; cloud-dependent values (Fn::GetAtt, Fn::ImportValue) are not supported with clear error messages

Supported Commands

  • sam build - Builds all expanded functions, preserves original template
  • sam package - Preserves Fn::ForEach structure with S3 URIs
  • sam deploy - Uploads original template for CloudFormation to process
  • sam validate - Validates language extension syntax
  • sam local invoke - Invokes expanded functions by name
  • sam local start-api - Serves ForEach-generated API endpoints
  • sam local start-lambda - Serves all expanded functions

Example

Transform:
  - AWS::LanguageExtensions
  - AWS::Serverless-2016-10-31

Resources:
  Fn::ForEach::Functions:
    - Name
    - [Alpha, Beta, Gamma]
    - ${Name}Function:
        Type: AWS::Serverless::Function
        Properties:
          Handler: ${Name}.handler
          CodeUri: ./src
          Runtime: python3.9

Resolves #5647

Testing

  • Comprehensive unit tests for the language extensions engine
  • Integration tests for all supported commands
  • Test templates covering static/dynamic CodeUri, nested stacks, parameter collections

Checklist

  • Unit tests added
  • Integration tests added
  • Documentation in code comments
  • Error messages include actionable workarounds

@bnusunny bnusunny requested a review from a team as a code owner February 9, 2026 00:16
@github-actions github-actions bot added area/package sam package command area/deploy sam deploy command area/build sam build command pr/internal labels Feb 9, 2026
@bnusunny bnusunny force-pushed the feat-language-extension branch from 0be94d0 to 5d6cbf3 Compare February 9, 2026 00:33
@bnusunny
Copy link
Contributor Author

bnusunny commented Feb 9, 2026

@bnusunny bnusunny force-pushed the feat-language-extension branch 7 times, most recently from e68efa0 to 4ed8396 Compare February 13, 2026 02:55
@bnusunny bnusunny force-pushed the feat-language-extension branch 15 times, most recently from 5324dad to 707baad Compare February 18, 2026 23:59
@bnusunny bnusunny force-pushed the feat-language-extension branch 2 times, most recently from 8caac65 to a42f5f0 Compare February 20, 2026 07:03
bnusunny

This comment was marked as outdated.

@bnusunny bnusunny force-pushed the feat-language-extension branch from a42f5f0 to 64665ff Compare February 20, 2026 17:55
@bnusunny bnusunny force-pushed the feat-language-extension branch 11 times, most recently from 5aa92bc to c83b1a3 Compare February 25, 2026 23:12
Implement a local CloudFormation Language Extensions processor supporting:
- Fn::ForEach loop expansion in Resources, Conditions, and Outputs
- Fn::Length, Fn::ToJsonString intrinsic functions
- Fn::FindInMap with DefaultValue support
- Conditional DeletionPolicy/UpdateReplacePolicy
- Nested ForEach depth validation (max 5 levels)
- Partial resolution mode preserving unresolvable references

Pipeline architecture: TemplateParsingProcessor -> ForEachProcessor ->
IntrinsicResolverProcessor -> DeletionPolicyProcessor ->
UpdateReplacePolicyProcessor

Includes comprehensive unit tests and CloudFormation compatibility suite.
Wire the language extensions library into SAM CLI with two-phase architecture:
- Phase 1: expand_language_extensions() -> LanguageExtensionResult
- Phase 2: SamTranslatorWrapper.run_plugins() (SAM transform only)

Key components:
- expand_language_extensions() canonical entry point
- SamTranslatorWrapper receives pre-expanded template (Phase 2 only)
- SamLocalStackProvider.get_stacks() calls expand_language_extensions()
- SamTemplateValidator calls expand_language_extensions()
- DynamicArtifactProperty dataclass for Mappings transformation
- Fn::ForEach guards in artifact_exporter, normalizer, cdk/utils
- _get_template_for_output() preserves Fn::ForEach in build output
- _update_foreach_artifact_paths() generates Mappings for dynamic
  artifact properties with per-function build paths
- Recursive nested Fn::ForEach support
- ForEach-aware path resolution skips Docker image URIs

Test templates: static CodeUri, dynamic CodeUri, parameter collections,
nested stacks, nested ForEach, dynamic ImageUri, depth validation.
Package:
- _export() calls expand_language_extensions() for Phase 1
- Preserves Fn::ForEach in packaged template with S3 URIs
- Generates Mappings for dynamic artifact properties
- _find_artifact_uri_for_resource() handles all export formats:
  string, {S3Bucket,S3Key}, {Bucket,Key}, {ImageUri}
- Recursive nested Fn::ForEach support
- Warning for parameter-based collections

Deploy:
- Uploads original unexpanded template to CloudFormation
- Clear error for missing Mapping keys

Integration tests for CodeUri, ContentUri, DefinitionUri, ImageUri,
BodyS3Location across all packageable resource types.
@bnusunny bnusunny force-pushed the feat-language-extension branch 2 times, most recently from dd84085 to a1100e9 Compare February 25, 2026 23:26
- sam validate: valid ForEach, invalid syntax, cloud-dependent collections,
  dynamic CodeUri, nested depth validation (5 valid, 6 invalid)
- sam local invoke: expanded function names from ForEach
- sam local start-api: ForEach-generated API endpoints
@bnusunny bnusunny force-pushed the feat-language-extension branch from a1100e9 to 6b4513b Compare February 25, 2026 23:31
Track CFNLanguageExtensions as a UsedFeature event when templates
with AWS::LanguageExtensions transform are expanded. Emitted once
per expansion in expand_language_extensions().
Copy link
Contributor

@reedham-aws reedham-aws left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my review of the first commit that adds all the language extension support in its own library. I didn't review the tests, but I really don't think that's feasible to review due to the sheer size.

Comment on lines +42 to +53
Note: The actual loop expansion logic is implemented in task 12.2.
This task (12.1) focuses on detection, validation, and collection resolution.

Requirements:
- 6.7: WHEN Fn::ForEach has invalid layout (wrong number of arguments,
invalid types), THEN THE Resolver SHALL raise an Invalid_Template_Exception
- 6.8: WHEN Fn::ForEach collection contains a Ref to a parameter,
THEN THE Resolver SHALL resolve the parameter value before iteration
- 18.2: WHEN a template contains 5 or fewer levels of nested Fn::ForEach loops,
THE SAM_CLI SHALL process the template successfully
- 18.3: WHEN a template contains more than 5 levels of nested Fn::ForEach loops,
THE SAM_CLI SHALL raise an error before processing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave one comment here but as a general issue I don't think we should be having task identifiers in the code like this. They're not particularly useful, especially because those docs aren't even committed.

Comment on lines +55 to +68
Example:
>>> processor = ForEachProcessor()
>>> context = TemplateProcessingContext(
... fragment={
... "Resources": {
... "Fn::ForEach::Topics": [
... "TopicName",
... ["Alerts", "Notifications"],
... {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}
... ]
... }
... }
... )
>>> processor.process_template(context)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm fine having these doctest strings, but we don't actually use them for anything so they're a little clunky. We don't do this anywhere else in the code.

Comment on lines +86 to +87
@staticmethod
def _merge_expanded(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: I think having this as @staticmethod conflicts with the name starting with _.


return result

def _process_section(self, section: Any, context: TemplateProcessingContext, section_name: str) -> Any:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me like _process_section and _process_properties are exactly the same. The section_name parameter isn't even used here, so I feel like we could merge the methods and start using it to differentiate.

# Resolve and validate identifier
identifiers = self._resolve_identifiers(identifier, context)
if not identifiers:
raise InvalidTemplateException(f"{key} layout is incorrect")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use the same hardcoded string several times in this method, probably better to have it in one place and use a variable.

# Format error message like Kotlin: "Found circular condition dependency between X and Y"
if len(cycle) >= 2:
raise InvalidTemplateException(
f"Found circular condition dependency between {cycle[0]} and {cycle[1]}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not show anything for something in the list greater than index 1?

Comment on lines +483 to +486
try:
self._resolver.resolve_value({"Condition": condition_name})
except InvalidTemplateException:
raise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try/except not necessary if you're going to raise the exception with no processing.


# Check each condition for cycles
for condition_name in conditions:
visited.clear()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think visited needs to be cleared here, would we not end up rechecking the same nodes?

Comment on lines +46 to +60
Example:
>>> template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}}
>>> json_str = serialize_to_json(template)
>>> print(json_str)
{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue"
}
}
}

Requirements:
- 13.1: Provide function to serialize to JSON format
- 13.3: Produce valid JSON that can be parsed back
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need all this documentation to wrap json.dumps

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably same for yaml down below too.

Comment on lines +13 to +15
def is_foreach_key(key: str) -> bool:
"""Check if a resource key is a Fn::ForEach block."""
return isinstance(key, str) and key.startswith(FOREACH_PREFIX)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is duplicate to one in processors/foreach.py

Copy link
Contributor

@reedham-aws reedham-aws left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My review of the second commit; once again, I did not review any tests.

return template_dict


def _update_foreach_relative_paths(foreach_value, original_root, new_root):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the code in this method is a direct copy of _update_relative_paths. I get that we need to do some preprocessing, but let's simplify so we're not copying code like this.

Comment on lines +511 to +519
resource_type = resource_def.get("Type")
if resource_type in packageable_resources.keys():
# Flatten list of locations per resource type
locations = list(itertools.chain(*packageable_resources.get(resource_type)))
for location in locations:
properties = resource_def.get("Properties", {})
# Search for package-able location within resource properties
if jmespath.search(location, properties):
artifacts.append(properties.get("PackageType", ZIP))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is copied from get_template_artifacts_format. Lets break it into a helper.

Comment on lines +545 to +547
# Return the ForEach key as a placeholder - the actual function IDs
# will be determined after language extensions are processed
_function_resource_ids.append(resource_id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure it does, but without looking further when does this happen? I want to make sure we're not forgetting this.


from samcli.commands._utils.experimental import ExperimentalFlag, is_experimental_enabled
from samcli.commands.package import exceptions
from samcli.lib.cfn_language_extensions.utils import iter_resources
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good method to use, but it seems like it was kind of unevenly applied. For example, in samcli/lib/package/artifact_exporter.py, it's not really used at all when it could be. There might be other places where it can be used that I haven't seen yet.

I also think the name is not descriptive enough to show that it's skipping foreach blocks.

return None


def contains_loop_variable(value: Any, loop_variable: str) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I was confused by loop_variable here. In other places in the library (eg. the _expand_foreach method in foreach.py) this is referred to as the identifier. I think they both make sense, and I don't think it's worth changing, just wanted to call that out.

Comment on lines +269 to +270
from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be at top level

DynamicArtifactProperty,
)

foreach_required_elements = 3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have this as a global in samcli/commands/_utils/template.py. I think it makes more sense to be defined as a global in this file.

@@ -62,6 +63,9 @@ def normalize(template_dict, normalize_parameters=False):
resources = template_dict.get(RESOURCES_KEY, {})

for logical_id, resource in resources.items():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't comment on more, but here is a time we could use iter_resources.


@staticmethod
def _check_using_language_extension(template: Dict) -> bool:
def _check_using_language_extension(template: Optional[Dict]) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of changing this method to wrap something from the library, lets just use that method instead.

Update all other member that also depends on the stacks.
This should be called whenever there is a change to the template.
"""
from samcli.lib.cfn_language_extensions.sam_integration import clear_expansion_cache
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be top level

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the exact amount of integration tests that would be good for this feature would be, but I feel like this is too many. I think our testing strategy should be more about testing the library itself; if that works, then builds should work too. A basic case wouldn't hurt, but I think that we risk really bloating our test suites here. Very much open to the opinions of others.

@reedham-aws
Copy link
Contributor

I've also noticed there's not a lot of logging in this PR. I think that would be good to have in some places, but it would be difficult to add all at once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build sam build command area/deploy sam deploy command area/package sam package command pr/internal

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Support LanguageExtensions feature Fn::ForEach

2 participants