|
7 | 7 | import re |
8 | 8 | from datetime import datetime, timezone |
9 | 9 | 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 |
11 | 11 | from uuid import uuid4 |
12 | 12 |
|
13 | 13 | from boto3 import Session |
@@ -348,6 +348,11 @@ def _auto_skip_infra_sync( |
348 | 348 | ): |
349 | 349 | return False |
350 | 350 |
|
| 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 | + |
351 | 356 | LOG.debug("There are no changes from the previously deployed template for %s", packaged_template_path) |
352 | 357 | return True |
353 | 358 |
|
@@ -399,6 +404,8 @@ def _sanitize_template( |
399 | 404 |
|
400 | 405 | for resource_logical_id in resources: |
401 | 406 | 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) |
402 | 409 | continue |
403 | 410 |
|
404 | 411 | resource_dict = resources.get(resource_logical_id, {}) |
@@ -497,6 +504,130 @@ def _remove_resource_field( |
497 | 504 |
|
498 | 505 | return processed_logical_id |
499 | 506 |
|
| 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 | + |
500 | 631 | def get_template(self, template_path: str) -> Optional[Dict]: |
501 | 632 | """ |
502 | 633 | Returns the template dict based on local or remote read logic |
|
0 commit comments