Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
3 changes: 2 additions & 1 deletion samcli/commands/deploy/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ def do_cli(
config_file=config_file,
disable_rollback=disable_rollback,
language_extensions_enabled=language_extensions_enabled,
role_arn=role_arn,
)
guided_context.run()
else:
Expand All @@ -362,7 +363,7 @@ def do_cli(
# after we figure out how to enable resolve-images-repos in package
if resolve_image_repos:
image_repositories = sync_ecr_stack(
template_file, stack_name, region, s3_bucket, s3_prefix, image_repositories
template_file, stack_name, region, s3_bucket, s3_prefix, image_repositories, role_arn=role_arn

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 fix only covers the non-guided branch, but --role-arn applies to guided deploys too — it is passed to DeployContext unconditionally at command.py:416, and it is also persisted into samconfig.toml. That means sam deploy --guided --role-arn <role> --resolve-image-repos still reproduces the exact failure from #5051: the main stack deploys under the service role, while the companion stack is created with the caller's credentials and fails on ecr:CreateRepository.

Both companion-stack entry points in the guided flow are affected:

  • samcli/commands/deploy/guided_context.py:191sync_ecr_stack(...) when --resolve-image-repos is set
  • samcli/commands/deploy/guided_context.py:362CompanionStackManager(stack_name, region, s3_bucket, s3_prefix) in prompt_image_repository, which calls manager.sync_repos() at line 390 and therefore reaches update_companion_stack() / create_stack

Threading role_arn through GuidedContext would close the gap, e.g. accept it in GuidedContext.__init__, pass role_arn=role_arn from the guided branch of do_cli, and forward it at both call sites:

# guided_context.py, in guided_prompts
image_repositories = (
   sync_ecr_stack(
       self.template_file,
       stack_name,
       region,
       managed_s3_bucket,
       self.s3_prefix,
       self.image_repositories,
       role_arn=self.role_arn,
   )
   if self.resolve_image_repositories
   else self.prompt_image_repository(...)
)

If leaving guided out is deliberate, it is worth stating why in the PR description, since the reported symptom is identical in that path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — you're right, the guided path had the same bug. GuidedContext now accepts role_arn, do_cli passes --role-arn through on the guided branch, and it's forwarded at both companion-stack call sites (sync_ecr_stack in guided_prompts and the CompanionStackManager in prompt_image_repository). Added guided tests asserting the role reaches both. Verified: 24/24 guided-context tests and all companion-stack manager tests pass.

)
with osutils.tempfile_platform_independent() as output_template_file:
if guided:
Expand Down
14 changes: 12 additions & 2 deletions samcli/commands/deploy/guided_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def __init__(
config_file=None,
disable_rollback=None,
language_extensions_enabled: bool = False,
role_arn: Optional[str] = None,
):
self.template_file = template_file
self.stack_name = stack_name
Expand Down Expand Up @@ -97,6 +98,9 @@ def __init__(
self.function_provider: Optional[SamFunctionProvider] = None
self.disable_rollback = disable_rollback
self._language_extensions_enabled = language_extensions_enabled
# Passed through to companion-stack operations so guided deploys honor
# --role-arn, matching the non-guided path.
self.role_arn = role_arn

@property
def guided_capabilities(self):
Expand Down Expand Up @@ -189,7 +193,13 @@ def guided_prompts(self, parameter_override_keys):

image_repositories = (
sync_ecr_stack(
self.template_file, stack_name, region, managed_s3_bucket, self.s3_prefix, self.image_repositories
self.template_file,
stack_name,
region,
managed_s3_bucket,
self.s3_prefix,
self.image_repositories,
role_arn=self.role_arn,
)
if self.resolve_image_repositories
else self.prompt_image_repository(
Expand Down Expand Up @@ -359,7 +369,7 @@ def prompt_image_repository(
if repo_full_path:
updated_repositories[repo_full_path] = image_repo_uri
self.function_provider = SamFunctionProvider(stacks, ignore_code_extraction_warnings=True)
manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix)
manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix, role_arn=self.role_arn)

function_logical_ids = [
function.full_path for function in self.function_provider.get_all() if function.packagetype == IMAGE
Expand Down
82 changes: 71 additions & 11 deletions samcli/lib/bootstrap/companion_stack/companion_stack_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

import logging
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional

import boto3
from botocore.config import Config
Expand Down Expand Up @@ -41,19 +41,27 @@ class CompanionStackManager:
_cfn_client: CloudFormationClient
_s3_client: S3Client

def __init__(self, stack_name, region, s3_bucket, s3_prefix):
def __init__(self, stack_name, region, s3_bucket, s3_prefix, role_arn: Optional[str] = None):
self._companion_stack = CompanionStack(stack_name)
self._builder = CompanionStackBuilder(self._companion_stack)
self._boto_config = Config(region_name=region if region else None)
self._update_stack_waiter_config = {"Delay": 10, "MaxAttempts": 120}
self._delete_stack_waiter_config = {"Delay": 10, "MaxAttempts": 120}
self._s3_bucket = s3_bucket
self._s3_prefix = s3_prefix
self._role_arn = role_arn
try:
self._cfn_client = boto3.client("cloudformation", config=self._boto_config)
self._ecr_client = boto3.client("ecr", config=self._boto_config)
self._s3_client = boto3.client("s3", config=self._boto_config)
self._account_id = boto3.client("sts").get_caller_identity().get("Account")
sts_client = boto3.client("sts")
self._account_id = sts_client.get_caller_identity().get("Account")
if role_arn:

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 constructor now calls sts:AssumeRole on the --role-arn value unconditionally whenever it is set. That is very likely to break the exact command this PR is trying to fix.

--role-arn is a CloudFormation service role: the only principal that needs to be in its trust policy is cloudformation.amazonaws.com. The deploying user/CI principal generally has no sts:AssumeRole permission on it, and the role's trust policy generally does not trust that principal. In that (common) configuration, _ecr_client_for_role raises AWSServiceClientError and sam deploy --role-arn <role> --resolve-image-repos fails hard at manager construction — before any stack or ECR work happens. Users who previously succeeded (main stack under the service role, ECR calls under their own credentials, which they were permitted to make) would now be blocked. This also contradicts the stated side effects in the PR description, which only mentions passing RoleARN to create_stack/update_stack.

The assume also happens eagerly even when no direct ECR call will ever be made (the only use of _ecr_client is delete_unreferenced_repos, which is a no-op when there are no stale repos), so the failure is triggered on paths that need no extra permissions at all.

Suggested direction: keep the RoleARN wiring on the CloudFormation calls (that part correctly fixes #5051), and make the assume-role behavior non-fatal and lazy — only attempt it when a direct ECR call is actually about to be made, and fall back to the caller's credentials with a LOG.debug/warning if the role cannot be assumed:

def getecr_client(self):
   """Return the assumed-role ECR client when available, else the caller's client."""
   if not self._role_arn:
       return self._ecr_client
   if self._role_ecr_client is None:
       try:
           self._role_ecr_client = self._ecr_client_for_role(boto3.client("sts"), self._role_arn)
       except AWSServiceClientError as ex:
           LOG.debug("Unable to assume %s for ECR operations, using caller credentials: %s", self._role_arn, ex)
           self._role_ecr_client = self._ecr_client
   return self._role_ecr_client

If assuming the service role is genuinely intended as a hard requirement, that is a user-visible behavior change that needs to be called out in the PR description and documentation, not just an internal detail.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call — that was a real regression I introduced. The eager assume is gone from the constructor; the role is now assumed lazily only when a direct ECR call is actually about to happen, and an assume failure just falls back to caller credentials with a debug log instead of aborting the deploy. When no --role-arn is set, nothing changed. CloudFormation create/update/delete keep their RoleARN wiring. Reworked the tests: assume is attempted lazily and exactly once, the failure case now asserts construction succeeds and ECR deletes run under caller creds, plus a case that no STS call happens when there are no stale repos.

# The CloudFormation RoleARN only covers calls CloudFormation
# makes on our behalf, not SDK calls SAM CLI makes itself
# (e.g. deleting unreferenced ECR repos). Assume the same role
# so all companion-stack side effects use one identity.
self._ecr_client = self._ecr_client_for_role(sts_client, role_arn)
self._region_name = self._cfn_client.meta.region_name
except NoCredentialsError as ex:
raise AWSServiceClientError(
Expand All @@ -68,6 +76,42 @@ def __init__(self, stack_name, region, s3_bucket, s3_prefix):
"Please provide a region via the --region parameter or by the AWS_DEFAULT_REGION environment variable."
) from ex

def _ecr_client_for_role(self, sts_client, role_arn: str):
"""
Assume the deployment service role and return an ECR client that uses
the assumed-role credentials.

Parameters
----------
sts_client
STS client used to assume the role
role_arn : str
ARN of the service role (e.g. from --role-arn) to assume

Returns
-------
An ECR client authenticated with the assumed-role credentials

Raises
------
AWSServiceClientError
If the role cannot be assumed
"""
try:
assumed_role = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="sam-cli-companion-stack")
except ClientError as ex:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The _get_ecr_client docstring states that "any failure falls back to the caller's credentials rather than aborting the deploy", but only ClientError is translated into AWSServiceClientError here — and _get_ecr_client catches only AWSServiceClientError. Any other assume_role failure escapes the fallback and aborts the deploy from a path that is explicitly designed never to abort.

This is reachable without an unusual setup: --role-arn is an unvalidated free-form string (role_arn_click_option in samcli/commands/_utils/options.py declares no type or callback), and botocore client-side validates RoleArn before the call. A typo'd or truncated value raises ParamValidationError, not ClientError. Because sync_repos() runs delete_unreferenced_repos() before update_companion_stack(), the user gets a raw botocore traceback instead of the clean CloudFormation validation error they would have gotten previously. Endpoint/connection errors from STS have the same effect.

Widening the caught type keeps the documented behavior intact:

from botocore.exceptions import BotoCoreError, ClientError

try:
   assumed_role = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="sam-cli-companion-stack")
except (ClientError, BotoCoreError) as ex:
   raise AWSServiceClientError(
       f"Error assuming the provided role {role_arn} for companion stack ECR operations: {ex}"
   ) from ex

BotoCoreError is the base for ParamValidationError, NoCredentialsError, and the endpoint/connection errors, so the fallback then covers every realistic STS failure.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point — a malformed role ARN fails with ParamValidationError before any ClientError is raised, so the fallback would never trigger there. _ecr_client_for_role now catches BotoCoreError as well, and I added a test with a malformed ARN covering the fallback.

raise AWSServiceClientError(
f"Error assuming the provided role {role_arn} for companion stack ECR operations: {ex}"
) from ex
credentials = assumed_role["Credentials"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The try block only wraps the assume_role call, so the "always falls back" contract documented in _get_ecr_client is still incomplete — this is the unresolved remainder of the earlier error-handling finding. Broadening the caught types to (ClientError, BotoCoreError) fixed the exception-breadth half, but the two statements that consume the response are outside the protected region:

except (ClientError, BotoCoreError) as ex:
       raise AWSServiceClientError(...) from ex
   credentials = assumed_role["Credentials"]   # outside try
   return boto3.client(                        # outside try
       "ecr",
       config=self._boto_config,
       aws_access_key_id=credentials["AccessKeyId"],
       ...
   )

_get_ecr_client catches only AWSServiceClientError, so anything raised by these two statements (a KeyError on a response shape that lacks Credentials/AccessKeyId, or a botocore error from client construction) propagates out of delete_unreferenced_repossync_repos and aborts the deploy from a code path whose whole purpose is to never abort. That is the same class of failure the fallback was added to prevent, and the fix is to move both statements inside the protected block:

try:
       assumed_role = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="sam-cli-companion-stack")
       credentials = assumed_role["Credentials"]
       return boto3.client(
           "ecr",
           config=self._boto_config,
           aws_access_key_id=credentials["AccessKeyId"],
           aws_secret_access_[REDACTED]
           aws_session_[REDACTED]
       )
   except (ClientError, BotoCoreError, KeyError) as ex:
       raise AWSServiceClientError(
           f"Error assuming the provided role {role_arn} for companion stack ECR operations: {ex}"
       ) from ex

This also makes the Raises section of the docstring accurate for the whole method rather than just its first line.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — the try block now also covers credential extraction and ECR client construction, so any failure there (malformed AssumeRole response, client construction errors) raises AWSServiceClientError and falls back to the caller-credential client.

return boto3.client(
"ecr",
config=self._boto_config,
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
)

def set_functions(
self, function_logical_ids: List[str], image_repositories: Optional[Dict[str, str]] = None
) -> None:
Expand Down Expand Up @@ -117,16 +161,21 @@ def update_companion_stack(self) -> None:
template_url = s3_uploader.to_path_style_s3_url(parts["Key"], parts.get("Version", None))

exists = self.does_companion_stack_exist()
# Use the service role passed via --role-arn (if any) so the companion stack is created/updated
# with the same permissions as the main stack.
stack_kwargs: Dict[str, Any] = {
"StackName": stack_name,
"TemplateURL": template_url,
"Capabilities": ["CAPABILITY_AUTO_EXPAND"],
}
if self._role_arn:
stack_kwargs["RoleARN"] = self._role_arn

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 service role is now wired into create_stack/update_stack, but sync_repos() also reaches ECR directly, and that path still runs under the caller's credentials — so the failure mode from #5051 remains reachable.

def sync_repos(self) -> None:
   has_repo = bool(self.get_repository_mapping())
   if self.does_companion_stack_exist():
       self.delete_unreferenced_repos()   # <-- direct ECR call, caller creds
       ...
def delete_unreferenced_repos(self) -> None:
   repos = self.get_unreferenced_repos()
   for repo in repos:
       try:
           self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
       except self._ecr_client.exceptions.RepositoryNotFoundException:
           LOG.debug(...)

self._ecr_client is built from ambient credentials in __init__; a CloudFormation RoleARN only affects calls CloudFormation makes on your behalf, not SDK calls SAM CLI makes itself. For the user profile described in the issue (caller has no ECR permissions, the --role-arn role does), removing an image function from the template and re-deploying with --resolve-image-repos --role-arn will raise an AccessDenied ClientError on ecr:DeleteRepository. That error is not caught here (only RepositoryNotFoundException is), so the deploy aborts with a raw botocore error before the companion stack update runs.

Two options, either is fine:

  • Assume role_arn via STS and build self._ecr_client from those credentials when role_arn is set, so all companion-stack side effects use one identity.
  • If keeping caller credentials for ECR is intentional, catch ClientError with AccessDeniedException here and raise an actionable message explaining that ecr:DeleteRepository is required on the calling identity even when --role-arn is supplied.

Worth covering with a unit test for the unreferenced-repo path, since the current tests exercise only create/update.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — when role_arn is set, the manager now assumes it via STS and builds self._ecr_client from the assumed-role credentials, so delete_unreferenced_repos and all other direct ECR calls run under one identity. An assume-role failure raises an actionable AWSServiceClientError. Added tests covering the assumed-role delete path and the failure case.

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 service role is applied to create_stack/update_stack but not to _delete_companion_stack, which is reachable from sync_repos() whenever the last image function is removed:

if self.does_companion_stack_exist():
   self.delete_unreferenced_repos()
   if has_repo:
       self.update_companion_stack()
   else:
       self._delete_companion_stack()   # no RoleARN

DeleteStack accepts RoleARN. When omitted, CloudFormation reuses the role previously associated with the stack — but any companion stack created before this fix (or created without --role-arn) has no associated role, so CloudFormation falls back to a session derived from the caller's credentials and the stack delete can fail with the same AccessDenied class of error as #5051, this time on repository deletion. Passing the role keeps the whole companion-stack lifecycle on one identity:

def deletecompanion_stack(self) -> None:
   stack_name = self._companion_stack.stack_name
   waiter = self._cfn_client.get_waiter("stack_delete_complete")
   delete_kwargs: Dict[str, Any] = {"StackName": stack_name}
   if self._role_arn:
       delete_kwargs["RoleARN"] = self._role_arn
   self._cfn_client.delete_stack(**delete_kwargs)
   waiter.wait(StackName=stack_name, WaiterConfig=self._delete_stack_waiter_config)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed — _delete_companion_stack now passes RoleARN on delete_stack when the role is set, so the whole companion-stack lifecycle (create/update/delete) runs under one identity. Also covered by a new test asserting delete_stack gets the RoleARN kwarg; the no-role case still deletes with just StackName.

if exists:
self._cfn_client.update_stack(
StackName=stack_name, TemplateURL=template_url, Capabilities=["CAPABILITY_AUTO_EXPAND"]
)
self._cfn_client.update_stack(**stack_kwargs)
update_waiter = self._cfn_client.get_waiter("stack_update_complete")
update_waiter.wait(StackName=stack_name, WaiterConfig=self._update_stack_waiter_config)
else:
self._cfn_client.create_stack(
StackName=stack_name, TemplateURL=template_url, Capabilities=["CAPABILITY_AUTO_EXPAND"]
)
self._cfn_client.create_stack(**stack_kwargs)
create_waiter = self._cfn_client.get_waiter("stack_create_complete")
create_waiter.wait(StackName=stack_name, WaiterConfig=self._update_stack_waiter_config)

Expand Down Expand Up @@ -279,7 +328,13 @@ def is_repo_uri(self, repo_uri: Optional[str], function_logical_id: str) -> bool


def sync_ecr_stack(
template_file: str, stack_name: str, region: str, s3_bucket: str, s3_prefix: str, image_repositories: Dict[str, str]
template_file: str,
stack_name: str,
region: str,
s3_bucket: str,
s3_prefix: str,
image_repositories: Dict[str, str],
role_arn: Optional[str] = None,
) -> Dict[str, str]:
"""Blocking call to sync local functions with ECR Companion Stack

Expand All @@ -297,6 +352,11 @@ def sync_ecr_stack(
S3 prefix for the bucket
image_repositories : Dict[str, str]
Mapping between function logical ID and ECR URI
role_arn : Optional[str]
Optional service role ARN used when creating or updating the companion stack.
When provided, the companion stack is created/updated with the same service
role as the main stack, and direct ECR calls made by SAM CLI (e.g. deleting
unreferenced repositories) assume the role so they run under one identity.

Returns
-------
Expand All @@ -305,7 +365,7 @@ def sync_ecr_stack(
for Functions without a repo specified.
"""
image_repositories = image_repositories.copy() if image_repositories else {}
manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix)
manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix, role_arn=role_arn)

stacks = SamLocalStackProvider.get_stacks(template_file, language_extensions_enabled=False)[0]
function_provider = SamFunctionProvider(stacks, ignore_code_extraction_warnings=True)
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/commands/deploy/test_guided_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,120 @@ def test_guided_prompts_check_default_config_region(
),
]
self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list)


class TestGuidedContextRoleArn(TestCase):
ROLE_ARN = "arn:aws:iam::123456789012:role/deploy-role"

def setUp(self):
self.gc = GuidedContext(
template_file="template",
stack_name="test",
s3_bucket="s3_b",
s3_prefix="s3_p",
confirm_changeset=True,
region="region",
image_repository=None,
image_repositories={"RandomFunction": "image-repo"},
disable_rollback=False,
role_arn=self.ROLE_ARN,
)
self.companion_stack_manager_patch = patch("samcli.commands.deploy.guided_context.CompanionStackManager")
self.companion_stack_manager_mock = self.companion_stack_manager_patch.start()
self.sync_ecr_stack_patch = patch("samcli.commands.deploy.guided_context.sync_ecr_stack")
self.sync_ecr_stack_mock = self.sync_ecr_stack_patch.start()
self.addCleanup(self.companion_stack_manager_patch.stop)
self.addCleanup(self.sync_ecr_stack_patch.stop)

def test_guided_context_stores_role_arn(self):
self.assertEqual(self.gc.role_arn, self.ROLE_ARN)
default_gc = GuidedContext(
template_file="template",
stack_name="test",
s3_bucket="s3_b",
s3_prefix="s3_p",
region="region",
image_repository=None,
image_repositories={},
)
self.assertIsNone(default_gc.role_arn)

@patch("samcli.commands.deploy.guided_context.get_resource_full_path_by_id")
@patch("samcli.commands.deploy.guided_context.prompt")
@patch("samcli.commands.deploy.guided_context.confirm")
@patch("samcli.commands.deploy.guided_context.manage_stack")
@patch("samcli.commands.deploy.guided_context.auth_per_resource")
@patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks")
@patch("samcli.commands.deploy.guided_context.signer_config_per_function")
def test_guided_prompts_forwards_role_arn_to_sync_ecr_stack(
self,
patched_signer_config_per_function,
patched_get_buildable_stacks,
patched_auth_per_resource,
patched_manage_stack,
patched_confirm,
patched_prompt,
get_resource_full_path_by_id_mock,
):
patched_get_buildable_stacks.return_value = (Mock(), [])
patched_signer_config_per_function.return_value = (None, None)
patched_prompt.side_effect = ["sam-app", "region", "CAPABILITY_IAM", "samconfig.toml", "default"]
patched_confirm.side_effect = [True, True, False, True]
patched_auth_per_resource.return_value = [("HelloWorldFunction", True)]
patched_manage_stack.return_value = "managed_s3_stack"
self.sync_ecr_stack_mock.return_value = {"HelloWorldFunction": "repo-uri"}

self.gc.resolve_image_repositories = True
self.gc.guided_prompts(parameter_override_keys=None)

self.sync_ecr_stack_mock.assert_called_once_with(
"template",
"sam-app",
"region",
"managed_s3_stack",
"s3_p",
{"RandomFunction": "image-repo"},
role_arn=self.ROLE_ARN,
)

@patch("samcli.commands.deploy.guided_context.get_resource_full_path_by_id")
@patch("samcli.commands.deploy.guided_context.prompt")
@patch("samcli.commands.deploy.guided_context.confirm")
@patch("samcli.commands.deploy.guided_context.manage_stack")
@patch("samcli.commands.deploy.guided_context.auth_per_resource")
@patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks")
@patch("samcli.commands.deploy.guided_context.SamFunctionProvider")
@patch("samcli.commands.deploy.guided_context.signer_config_per_function")
def test_prompt_image_repository_forwards_role_arn_to_companion_stack_manager(
self,
patched_signer_config_per_function,
patched_sam_function_provider,
patched_get_buildable_stacks,
patched_auth_per_resource,
patched_manage_stack,
patched_confirm,
patched_prompt,
get_resource_full_path_by_id_mock,
):
function_mock = MagicMock()
function_mock.packagetype = IMAGE
function_mock.imageuri = "helloworld:v1"
function_mock.full_path = "HelloWorldFunction"
patched_sam_function_provider.return_value.get_all.return_value = [function_mock]
patched_get_buildable_stacks.return_value = (Mock(), [])
patched_signer_config_per_function.return_value = (None, None)
patched_prompt.side_effect = ["sam-app", "region", "CAPABILITY_IAM", "samconfig.toml", "default"]
patched_confirm.side_effect = [True, True, False, True, True]
patched_auth_per_resource.return_value = [("HelloWorldFunction", True)]
get_resource_full_path_by_id_mock.return_value = None
patched_manage_stack.return_value = "managed_s3_stack"
# "do not create repos for all functions" then "do not delete unreferenced repos"
manager_instance = self.companion_stack_manager_mock.return_value
manager_instance.get_repository_mapping.return_value = {"HelloWorldFunction": "repo-uri"}
manager_instance.get_unreferenced_repos.return_value = []

self.gc.guided_prompts(parameter_override_keys=None)

self.companion_stack_manager_mock.assert_called_once_with(
"sam-app", "region", "managed_s3_stack", "s3_p", role_arn=self.ROLE_ARN
)
Loading