Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
125 changes: 111 additions & 14 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 @@ -40,20 +40,26 @@ class CompanionStackManager:
_s3_prefix: str
_cfn_client: CloudFormationClient
_s3_client: S3Client
_role_arn: Optional[str]
_sts_client: Any
_role_ecr_client: Any

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
self._role_ecr_client = None
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")
self._sts_client = boto3.client("sts")
self._account_id = self._sts_client.get_caller_identity().get("Account")
self._region_name = self._cfn_client.meta.region_name
except NoCredentialsError as ex:
raise AWSServiceClientError(
Expand All @@ -68,6 +74,72 @@ 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 _get_ecr_client(self):
"""
Return the ECR client to use for direct ECR calls (e.g. deleting
unreferenced repositories).

A CloudFormation service role's trust policy generally only trusts
cloudformation.amazonaws.com, so the deploying principal usually
cannot assume it; the assume is therefore attempted lazily — only
when a direct ECR call is actually about to be made — and any failure
falls back to the caller's credentials rather than aborting the
deploy. When no role ARN is configured, the caller's client is used.

Returns
-------
An ECR 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(self._sts_client, self._role_arn)
except AWSServiceClientError as ex:
LOG.debug(
"Unable to assume %s for companion stack ECR operations, " "falling back to caller credentials: %s",
self._role_arn,
ex,
)
self._role_ecr_client = self._ecr_client
return self._role_ecr_client

def set_functions(
self, function_logical_ids: List[str], image_repositories: Optional[Dict[str, str]] = None
) -> None:
Expand Down Expand Up @@ -117,16 +189,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 All @@ -136,7 +213,13 @@ def _delete_companion_stack(self) -> None:
"""
stack_name = self._companion_stack.stack_name
waiter = self._cfn_client.get_waiter("stack_delete_complete")
self._cfn_client.delete_stack(StackName=stack_name)
# Use the service role passed via --role-arn (if any) so the delete
# runs under the same identity as create/update; stacks created
# without a role would otherwise fall back to caller credentials.
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)

def list_deployed_repos(self) -> List[ECRRepo]:
Expand Down Expand Up @@ -191,10 +274,11 @@ def delete_unreferenced_repos(self) -> None:
If repo does not exist, this will simply skip it.
"""
repos = self.get_unreferenced_repos()
ecr_client = self._get_ecr_client()

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] _get_ecr_client() is called before checking whether there is anything to delete, so the sts:AssumeRole attempt happens even when repos is empty. That contradicts the laziness the method's own docstring promises ("only when a direct ECR call is actually about to be made").

The empty case is the steady state, not an edge case: sync_repos() calls delete_unreferenced_repos() on every deploy where the companion stack already exists, and there are usually no stale repos. Combined with the fact that a CloudFormation service role's trust policy generally does not include the deploying principal — as the docstring itself notes — this means every routine sam deploy --role-arn ... --resolve-image-repos emits a failed AssumeRole to CloudTrail for no reason, which is the kind of event security tooling alerts on.

Guarding on the repo list resolves it:

repos = self.get_unreferenced_repos()
if not repos:
   return
ecr_client = self._get_ecr_client()
for repo in repos:
   ...

Note on prior review threads: the four earlier findings (guided-path plumbing, RoleARN on _delete_companion_stack, the unconditional constructor-time assume_role, and the direct-ECR identity mismatch) are all resolved in this diff and were not re-raised.

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_unreferenced_repos now returns early when there are no candidate repos, so AssumeRole is never called in that case. Added a test asserting assume_role isn't invoked when nothing is stale.

for repo in repos:
try:
self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
except self._ecr_client.exceptions.RepositoryNotFoundException:
ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
except ecr_client.exceptions.RepositoryNotFoundException:

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] Routing delete_repository through the assumed service role changes the identity of this call for users whose role is assumable, but the except clause still only handles RepositoryNotFoundException. A CloudFormation service role is commonly scoped to what CFN needs to create/update the stack; if it can be assumed but lacks ecr:DeleteRepository, the resulting AccessDenied ClientError is unhandled and aborts sam deploy before packaging — a case that worked before this PR, because the delete previously always ran under the caller's (typically broader) credentials.

Note this is reachable on ordinary deploys, not just teardown: sync_repos() calls delete_unreferenced_repos() whenever the companion stack exists, so removing or renaming a single image function is enough.

Since the design already treats the assumed role as best-effort, the permission failure deserves the same treatment — fall back to the caller's client rather than failing the deploy:

for repo in repos:
           try:
               ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)
           except ecr_client.exceptions.RepositoryNotFoundException:
               LOG.debug("Image repo [%s] not found in companion stack. Skipping deletion.", repo.physical_id)
           except ClientError as ex:
               if ecr_client is self._ecr_client or ex.response.get("Error", {}).get("Code") != "AccessDeniedException":
                   raise
               LOG.debug(
                   "Assumed role not authorized to delete image repo [%s]; retrying with caller credentials.",
                   repo.physical_id,
               )
               self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True)

Re-raising for any other error code keeps genuine failures visible instead of silently swallowing them.

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 — if the assumed-role delete hits AccessDeniedException, we now retry the delete with the caller-credential client instead of aborting the deploy. Other errors still propagate.

LOG.debug("Image repo [%s] not found in companion stack. Skipping deletion.", repo.physical_id)

def sync_repos(self) -> None:
Expand Down Expand Up @@ -279,7 +363,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 +387,13 @@ 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, updating, or deleting
the companion stack. When provided, the companion stack is managed
with the same service role as the main stack, and direct ECR calls
made by SAM CLI (e.g. deleting unreferenced repositories) attempt to
assume the role, falling back to caller credentials if it cannot be
assumed.

Returns
-------
Expand All @@ -305,7 +402,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