Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions samcli/commands/local/cli_common/user_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ class ResourceNotFound(UserException):
"""


class AmbiguousResourceIdentifier(UserException):
"""
A bare resource logical ID (without a stack path) matched resources in more than one stack
"""


class InvalidLayerVersionArn(UserException):
"""
The LayerVersion Arn given in the template is Invalid
Expand Down
32 changes: 29 additions & 3 deletions samcli/lib/providers/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Union, cast
from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple, Union, cast

from samcli.commands.local.cli_common.user_exceptions import (
AmbiguousResourceIdentifier,
InvalidFunctionPropertyType,
InvalidLayerVersionArn,
UnsupportedIntrinsic,
Expand Down Expand Up @@ -883,8 +884,19 @@ def get_resource_by_id(
-------
Dict
Resource dict

Raises
------
AmbiguousResourceIdentifier
If a bare logical ID (no stack path given) matches resources in more than one *nested*
stack, with no match in the root stack to prefer. The root stack, if it has a match, always
takes priority (this is relied upon by existing behavior); but silently picking the first
nested-stack match in stack-list order when there is no root match - and more than one
nested stack collides - could operate on the wrong physical resource (e.g.
`sam sync --resource-id`), so that specific case is now a clear, actionable error instead.
"""
search_all_stacks = not identifier.stack_path and not explicit_nested
matches: List[Tuple[Stack, Dict[str, Any]]] = []
for stack in stacks:
if stack.stack_path == identifier.stack_path or search_all_stacks:
found_resource = None
Expand All @@ -897,8 +909,22 @@ def get_resource_by_id(
break

if found_resource:
return cast(Dict[str, Any], found_resource)
return None
if not stack.stack_path:
# The root stack always takes priority over nested stacks.
return cast(Dict[str, Any], found_resource)
matches.append((stack, found_resource))
if not search_all_stacks:
break

if len(matches) > 1:
colliding_paths = [get_full_path(stack.stack_path, identifier.resource_iac_id) for stack, _ in matches]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The remediation path in the error message is built from identifier.resource_iac_id, but a match can be found via either the normalized resource ID or the raw logical ID:

resource_id = ResourceMetadataNormalizer.get_resource_id(resource, logical_id)
if resource_id == identifier.resource_iac_id or (
   not identifier.stack_path and logical_id == identifier.resource_iac_id
):

The second branch is only reachable when identifier.stack_path is empty — which is exactly the ambiguous case this code raises on. So if the user passed a raw logical ID that differs from the normalized ID (CDK apps, or any template with SamResourceId/aws:cdk:path metadata — see ResourceMetadataNormalizer.get_resource_id), the message tells them to retry with e.g. NestedStackA/Function1ABC123. That qualified form can never resolve, because once stack_path is non-empty only resource_id == identifier.resource_iac_id is compared — the retry returns None and surfaces as "resource not found". The user is sent to a dead end.

Capture the matched resource ID and build the paths from it, the way get_resource_full_path_by_id already does (it returns get_full_path(stack.stack_path, resource_id)). This also makes the listed paths match the canonical full paths used by get_all_resource_ids:

matches: List[Tuple[str, Dict[str, Any]]] = []  # (full_path, resource)
for stack in stacks:
   if stack.stack_path == identifier.stack_path or search_all_stacks:
       found_resource = None
       found_resource_id = None
       for logical_id, resource in stack.resources.items():
           resource_id = ResourceMetadataNormalizer.get_resource_id(resource, logical_id)
           if resource_id == identifier.resource_iac_id or (
               not identifier.stack_path and logical_id == identifier.resource_iac_id
           ):
               found_resource = resource
               found_resource_id = resource_id
               break

       if found_resource:
           if not stack.stack_path:
               return cast(Dict[str, Any], found_resource)
           matches.append((get_full_path(stack.stack_path, cast(str, found_resource_id)), found_resource))
           if not search_all_stacks:
               break

if len(matches) > 1:
   colliding_paths = [full_path for full_path,  in matches]
   ...

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The remediation path in the ambiguity error is built from identifier.resource_iac_id, but a match can be established through either the normalized resource ID or the raw logical ID:

resource_id = ResourceMetadataNormalizer.get_resource_id(resource, logical_id)
if resource_id == identifier.resource_iac_id or (
   not identifier.stack_path and logical_id == identifier.resource_iac_id
):

When the match comes from the second branch (CDK / SamResourceId / aws:cdk:path resources, where get_resource_id() returns something other than the logical ID), the suggested path is Stack/<logicalId> — which cannot resolve. Once a stack path is present, the logical-ID fallback is disabled by the not identifier.stack_path guard, so a stack-qualified lookup only matches the normalized ID. The existing suite already pins this: tests/unit/commands/local/lib/test_provider.py asserts ResourceIdentifier("childStack/CDKResourceInChild1") resolves to None, while the bare CDKResourceInChild1 resolves to childStack/CDKResourceInChild1-x.

Concrete effect: a user with two sibling nested stacks each holding a CDK resource with logical ID Function1 gets told to run --resource-id NestedStackA/Function1, which then fails with a not-found error, leaving no working way to disambiguate. Note that get_resource_full_path_by_id (same PR, line 969) builds its message from the normalized resource_id, so the two error messages disagree for identical input.

Carry the matched ID alongside the resource so the message names an identifier that actually resolves:

matches: List[Tuple[Stack, str, Dict[str, Any]]] = []
for stack in stacks:
   if stack.stack_path == identifier.stack_path or search_all_stacks:
       found_resource = None
       found_resource_id = None
       for logical_id, resource in stack.resources.items():
           resource_id = ResourceMetadataNormalizer.get_resource_id(resource, logical_id)
           if resource_id == identifier.resource_iac_id or (
               not identifier.stack_path and logical_id == identifier.resource_iac_id
           ):
               found_resource = resource
               found_resource_id = resource_id
               break
       ...
       matches.append((stack, cast(str, found_resource_id), found_resource))

if len(matches) > 1:
   colliding_paths = [get_full_path(stack.stack_path, resource_id) for stack, resource_id,  in matches]

A regression test covering a resource carrying Metadata.SamResourceId in two sibling nested stacks would lock this in — the new test classes only use plain CFN resources where logical ID and normalized ID coincide, which is why the bug is invisible to them.

raise AmbiguousResourceIdentifier(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] The same "bare ID matches resources in more than one stack" condition is already handled elsewhere in the codebase with the opposite strategy — warn and pick deterministically (samcli/lib/providers/sam_function_provider.py:139-160):

# If multiple functions are found, only return one of them
if len(found_fs) > 1:
   found_fs.sort(key=lambda f0: f0.full_path.lower())
   message = (
       f"Multiple functions found with keyword {name}! Function {found_fs[0].full_path} will be "
       f"invoked! If it's not the function you are going to invoke, please choose one of them from below:"
   )
   LOG.warning(Colored().yellow(message))
   resolved_function = found_fs[0]

After this change the two resolvers disagree on the same user input: sam sync --resource-id Function1 hard-fails, while sam local invoke Function1 still silently picks the alphabetically-first full path with a warning. That matters for two reasons: the "operates on the wrong resource" hazard motivating this PR is only closed on one of the two resolution paths, and future maintainers now have two contradictory precedence rules to reason about (first-nested-match is an error here; lowest-sorted-full-path wins there).

Either align the two (reuse the warn-and-pick behavior, or extend the ambiguity error to SamFunctionProvider.get), or add a short comment next to this raise explaining why sync-style resolution must be stricter than invoke-style resolution, so the divergence is a documented decision rather than an accident.

f"Resource ID '{identifier.resource_iac_id}' is ambiguous: it matches resources in more than one "
f"nested stack ({', '.join(colliding_paths)}). Qualify it with the full stack path, "
f"e.g. '{colliding_paths[0]}'."
)

return cast(Dict[str, Any], matches[0][1]) if matches else None


def get_resource_full_path_by_id(stacks: List[Stack], identifier: ResourceIdentifier) -> Optional[str]:
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/commands/local/lib/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
FunctionBuildInfo,
)
from samcli.commands.local.cli_common.user_exceptions import (
AmbiguousResourceIdentifier,
InvalidLayerVersionArn,
UnsupportedIntrinsic,
InvalidFunctionPropertyType,
Expand Down Expand Up @@ -652,6 +653,55 @@ def test_get_resource_by_id_not_found(
self.assertEqual(result, None)


class TestGetResourceByIDAmbiguousNestedStacks(TestCase):
"""
Regression tests: a bare (unqualified) resource ID that matches resources in more than one
*nested* stack, with no root-stack resource to prefer, used to silently return whichever
stack happened to come first in the input list - which could point sync/invoke/logs operations
at the wrong deployed physical resource. This must now raise AmbiguousResourceIdentifier instead.
"""

def setUp(self) -> None:
super().setUp()
self.root_stack = MagicMock()
self.root_stack.stack_path = ""
self.root_stack.resources = {}

self.nested_stack_a = MagicMock()
self.nested_stack_a.stack_path = "NestedStackA"
self.nested_stack_a.resources = {"Function1": {"Properties": "BodyA"}}

self.nested_stack_b = MagicMock()
self.nested_stack_b.stack_path = "NestedStackB"
self.nested_stack_b.resources = {"Function1": {"Properties": "BodyB"}}

def test_raises_when_two_sibling_nested_stacks_collide(self):
resource_identifier = MagicMock()
resource_identifier.stack_path = ""
resource_identifier.resource_iac_id = "Function1"

with self.assertRaises(AmbiguousResourceIdentifier):
get_resource_by_id([self.root_stack, self.nested_stack_a, self.nested_stack_b], resource_identifier, False)

def test_does_not_raise_when_only_one_nested_stack_matches(self):
resource_identifier = MagicMock()
resource_identifier.stack_path = ""
resource_identifier.resource_iac_id = "Function1"

result = get_resource_by_id([self.root_stack, self.nested_stack_a], resource_identifier, False)
self.assertEqual(result, self.nested_stack_a.resources["Function1"])

def test_does_not_raise_when_explicitly_qualified_with_stack_path(self):
resource_identifier = MagicMock()
resource_identifier.stack_path = "NestedStackA"
resource_identifier.resource_iac_id = "Function1"

result = get_resource_by_id(
[self.root_stack, self.nested_stack_a, self.nested_stack_b], resource_identifier, False
)
self.assertEqual(result, self.nested_stack_a.resources["Function1"])


class TestGetResourceIDsByType(TestCase):
def setUp(self) -> None:
super().setUp()
Expand Down