Skip to content

Commit 4f63f21

Browse files
authored
feat: add --resolve-image-repos support to sam package (#8476)
* feat: add --resolve-image-repos support to sam package Implements issue #3888 to auto-create ECR repositories during packaging, matching sam deploy behavior. Enables package-once, deploy-many CI/CD workflows with managed ECR repos. - Add --resolve-image-repos CLI option to sam package - Call sync_ecr_stack() to auto-create managed ECR repositories - Add validation requiring --s3-bucket when flag is used - Add conflict detection with --image-repositories - Add unit tests for validation logic Closes #3888 * fix: address review feedback - remove unused exception, move import, add integration test * refactor: move PackageResolveS3AndS3NotSetError import to module level * style: fix Black formatting - remove trailing whitespace * changes made: - Add race condition tests for _create_layer_directory - Fix test_samconfig.py parameter count (16 params for resolve_image_repos) - Add resolve_image_repos verification tests in test_command.py - Add sync_ecr_stack execution test in test_package_context.py - Regenerate schema/samcli.json Fixes: Lines 288-308 (race condition handling) and lines 107-112 (resolve_image_repos path)
1 parent 17114df commit 4f63f21

10 files changed

Lines changed: 295 additions & 17 deletions

File tree

samcli/commands/package/command.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
kms_key_id_option,
1616
metadata_option,
1717
no_progressbar_option,
18+
resolve_image_repos_option,
1819
resolve_s3_option,
1920
s3_bucket_option,
2021
s3_prefix_option,
@@ -86,13 +87,14 @@ def resources_and_properties_help_string():
8687
@use_json_option
8788
@force_upload_option
8889
@resolve_s3_option
90+
@resolve_image_repos_option
8991
@metadata_option
9092
@signing_profiles_option
9193
@no_progressbar_option
9294
@common_options
9395
@aws_creds_options
9496
@save_params_option
95-
@image_repository_validation(support_resolve_image_repos=False)
97+
@image_repository_validation(support_resolve_image_repos=True)
9698
@pass_context
9799
@track_command
98100
@check_newer_version
@@ -115,6 +117,7 @@ def cli(
115117
metadata,
116118
signing_profiles,
117119
resolve_s3,
120+
resolve_image_repos,
118121
save_params,
119122
config_file,
120123
config_env,
@@ -140,6 +143,7 @@ def cli(
140143
ctx.region,
141144
ctx.profile,
142145
resolve_s3,
146+
resolve_image_repos,
143147
) # pragma: no cover
144148

145149

@@ -159,17 +163,22 @@ def do_cli(
159163
region,
160164
profile,
161165
resolve_s3,
166+
resolve_image_repos,
162167
):
163168
"""
164169
Implementation of the ``cli`` method
165170
"""
166171

172+
from samcli.commands.package.exceptions import PackageResolveS3AndS3NotSetError
167173
from samcli.commands.package.package_context import PackageContext
168174

169175
if resolve_s3:
170176
s3_bucket = manage_stack(profile=profile, region=region)
171177
print_managed_s3_bucket_info(s3_bucket)
172178

179+
if resolve_image_repos and not s3_bucket:
180+
raise PackageResolveS3AndS3NotSetError()
181+
173182
with PackageContext(
174183
template_file=template_file,
175184
s3_bucket=s3_bucket,
@@ -185,5 +194,6 @@ def do_cli(
185194
region=region,
186195
profile=profile,
187196
signing_profiles=signing_profiles,
197+
resolve_image_repos=resolve_image_repos,
188198
) as package_context:
189199
package_context.run()

samcli/commands/package/core/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
INFRASTRUCTURE_OPTION_NAMES: List[str] = [
1717
"s3_prefix",
18+
"resolve_image_repos",
1819
"image_repository",
1920
"image_repositories",
2021
"kms_key_id",

samcli/commands/package/package_context.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import click
2424

2525
from samcli.commands.package.exceptions import PackageFailedError
26+
from samcli.lib.bootstrap.companion_stack.companion_stack_manager import sync_ecr_stack
2627
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
2728
from samcli.lib.package.artifact_exporter import Template
2829
from samcli.lib.package.code_signer import CodeSigner
@@ -71,6 +72,7 @@ def __init__(
7172
parameter_overrides=None,
7273
on_deploy=False,
7374
signing_profiles=None,
75+
resolve_image_repos=False,
7476
):
7577
self.template_file = template_file
7678
self.s3_bucket = s3_bucket
@@ -89,6 +91,7 @@ def __init__(
8991
self.code_signer = None
9092
self.signing_profiles = signing_profiles
9193
self.parameter_overrides = parameter_overrides
94+
self.resolve_image_repos = resolve_image_repos
9295
self._global_parameter_overrides = {IntrinsicsSymbolTable.AWS_REGION: region} if region else {}
9396

9497
def __enter__(self):
@@ -101,6 +104,14 @@ def run(self):
101104
"""
102105
Execute packaging based on the argument provided by customers and samconfig.toml.
103106
"""
107+
if self.resolve_image_repos:
108+
template_basename = os.path.splitext(os.path.basename(self.template_file))[0]
109+
stack_name = f"sam-app-{template_basename}"
110+
111+
self.image_repositories = sync_ecr_stack(
112+
self.template_file, stack_name, self.region, self.s3_bucket, self.s3_prefix, self.image_repositories
113+
)
114+
104115
stacks, _ = SamLocalStackProvider.get_stacks(
105116
self.template_file,
106117
global_parameter_overrides=self._global_parameter_overrides,

schema/samcli.json

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,7 @@
11181118
"properties": {
11191119
"parameters": {
11201120
"title": "Parameters for the package command",
1121-
"description": "Available parameters for the package command:\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* output_template_file:\nThe path to the file where the command writes the output AWS CloudFormation template. If you don't specify a path, the command writes the template to the standard output.\n* s3_bucket:\nAWS S3 bucket where artifacts referenced in the template are uploaded.\n* image_repository:\nAWS ECR repository URI where artifacts referenced in the template are uploaded.\n* image_repositories:\nMapping of Function Logical ID to AWS ECR Repository URI.\n\nExample: Function_Logical_ID=ECR_Repo_Uri\nThis option can be specified multiple times.\n* s3_prefix:\nPrefix name that is added to the artifact's name when it is uploaded to the AWS S3 bucket.\n* kms_key_id:\nThe ID of an AWS KMS key that is used to encrypt artifacts that are at rest in the AWS S3 bucket.\n* use_json:\nIndicates whether to use JSON as the format for the output AWS CloudFormation template. YAML is used by default.\n* force_upload:\nIndicates whether to override existing files in the S3 bucket. Specify this flag to upload artifacts even if they match existing artifacts in the S3 bucket.\n* resolve_s3:\nAutomatically resolve AWS S3 bucket for non-guided deployments. Enabling this option will also create a managed default AWS S3 bucket for you. If one does not provide a --s3-bucket value, the managed bucket will be used. Do not use --guided with this option.\n* metadata:\nMap of metadata to attach to ALL the artifacts that are referenced in the template.\n* signing_profiles:\nA string that contains Code Sign configuration parameters as FunctionOrLayerNameToSign=SigningProfileName:SigningProfileOwner Since signing profile owner is optional, it could also be written as FunctionOrLayerNameToSign=SigningProfileName\n* no_progressbar:\nDoes not showcase a progress bar when uploading artifacts to S3 and pushing docker images to ECR\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.",
1121+
"description": "Available parameters for the package command:\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* output_template_file:\nThe path to the file where the command writes the output AWS CloudFormation template. If you don't specify a path, the command writes the template to the standard output.\n* s3_bucket:\nAWS S3 bucket where artifacts referenced in the template are uploaded.\n* image_repository:\nAWS ECR repository URI where artifacts referenced in the template are uploaded.\n* image_repositories:\nMapping of Function Logical ID to AWS ECR Repository URI.\n\nExample: Function_Logical_ID=ECR_Repo_Uri\nThis option can be specified multiple times.\n* s3_prefix:\nPrefix name that is added to the artifact's name when it is uploaded to the AWS S3 bucket.\n* kms_key_id:\nThe ID of an AWS KMS key that is used to encrypt artifacts that are at rest in the AWS S3 bucket.\n* use_json:\nIndicates whether to use JSON as the format for the output AWS CloudFormation template. YAML is used by default.\n* force_upload:\nIndicates whether to override existing files in the S3 bucket. Specify this flag to upload artifacts even if they match existing artifacts in the S3 bucket.\n* resolve_s3:\nAutomatically resolve AWS S3 bucket for non-guided deployments. Enabling this option will also create a managed default AWS S3 bucket for you. If one does not provide a --s3-bucket value, the managed bucket will be used. Do not use --guided with this option.\n* resolve_image_repos:\nAutomatically create and delete ECR repositories for image-based functions in non-guided deployments. A companion stack containing ECR repos for each function will be deployed along with the template stack. Automatically created image repositories will be deleted if the corresponding functions are removed.\n* metadata:\nMap of metadata to attach to ALL the artifacts that are referenced in the template.\n* signing_profiles:\nA string that contains Code Sign configuration parameters as FunctionOrLayerNameToSign=SigningProfileName:SigningProfileOwner Since signing profile owner is optional, it could also be written as FunctionOrLayerNameToSign=SigningProfileName\n* no_progressbar:\nDoes not showcase a progress bar when uploading artifacts to S3 and pushing docker images to ECR\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* save_params:\nSave the parameters provided via the command line to the configuration file.",
11221122
"type": "object",
11231123
"properties": {
11241124
"template_file": {
@@ -1175,6 +1175,11 @@
11751175
"type": "boolean",
11761176
"description": "Automatically resolve AWS S3 bucket for non-guided deployments. Enabling this option will also create a managed default AWS S3 bucket for you. If one does not provide a --s3-bucket value, the managed bucket will be used. Do not use --guided with this option."
11771177
},
1178+
"resolve_image_repos": {
1179+
"title": "resolve_image_repos",
1180+
"type": "boolean",
1181+
"description": "Automatically create and delete ECR repositories for image-based functions in non-guided deployments. A companion stack containing ECR repos for each function will be deployed along with the template stack. Automatically created image repositories will be deleted if the corresponding functions are removed."
1182+
},
11781183
"metadata": {
11791184
"title": "metadata",
11801185
"type": "string",
@@ -2202,11 +2207,12 @@
22022207
"title": "parameter_overrides",
22032208
"type": [
22042209
"array",
2210+
"object",
22052211
"string"
22062212
],
22072213
"description": "String that contains AWS CloudFormation parameter overrides encoded as key=value pairs.",
22082214
"items": {
2209-
"type": "string"
2215+
"$ref": "#/$defs/parameter_overrides_items"
22102216
}
22112217
},
22122218
"stack_name": {
@@ -2271,18 +2277,6 @@
22712277
"description": "Available parameters for the list stack outputs command:\n* stack_name:\nName of corresponding deployed stack.\n* output:\nOutput the results from the command in a given output format (json or table).\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.",
22722278
"type": "object",
22732279
"properties": {
2274-
"parameter_overrides": {
2275-
"title": "parameter_overrides",
2276-
"type": [
2277-
"array",
2278-
"object",
2279-
"string"
2280-
],
2281-
"description": "String that contains AWS CloudFormation parameter overrides encoded as key=value pairs.",
2282-
"items": {
2283-
"$ref": "#/$defs/parameter_overrides_items"
2284-
}
2285-
},
22862280
"stack_name": {
22872281
"title": "stack_name",
22882282
"type": "string",

tests/integration/package/package_integ_base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def get_command_list(
9999
image_repository=None,
100100
image_repositories=None,
101101
resolve_s3=False,
102+
resolve_image_repos=False,
102103
):
103104
command_list = [get_sam_command(), "package"]
104105

@@ -130,6 +131,8 @@ def get_command_list(
130131
command_list = command_list + ["--image-repositories", str(image_repositories)]
131132
if resolve_s3:
132133
command_list = command_list + ["--resolve-s3"]
134+
if resolve_image_repos:
135+
command_list = command_list + ["--resolve-image-repos"]
133136
return command_list
134137

135138
def _method_to_stack_name(self, method_name):

tests/integration/package/test_package_command_image.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,33 @@ def test_package_with_nonloadable_image_archive(self, template_file):
349349

350350
self.assertEqual(1, process.returncode)
351351
self.assertIn("unexpected EOF", process_stderr.decode("utf-8"))
352+
353+
@parameterized.expand(
354+
[
355+
"aws-serverless-function-image.yaml",
356+
"aws-lambda-function-image.yaml",
357+
]
358+
)
359+
def test_package_template_with_resolve_image_repos(self, template_file):
360+
361+
template_path = self.test_data_path.joinpath(template_file)
362+
command_list = PackageIntegBase.get_command_list(
363+
s3_bucket=self.bucket_name,
364+
template=template_path,
365+
resolve_image_repos=True,
366+
)
367+
368+
process = Popen(command_list, stdout=PIPE, stderr=PIPE)
369+
try:
370+
stdout, stderr = process.communicate(timeout=TIMEOUT)
371+
except TimeoutExpired:
372+
process.kill()
373+
raise
374+
375+
process_stdout = stdout.strip().decode("utf-8")
376+
process_stderr = stderr.strip().decode("utf-8")
377+
self.assertEqual(0, process.returncode, f"Command failed. Stderr: {process_stderr}")
378+
# Verify ECR repository URI is in the output (auto-created repository)
379+
# The output should contain an ECR repository URI pattern
380+
ecr_uri_pattern = r"\d+\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com/"
381+
self.assertRegex(process_stdout, ecr_uri_pattern, "Expected ECR repository URI in packaged template")

tests/unit/commands/package/test_command.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from unittest import TestCase
22
from unittest.mock import patch, Mock
33

4-
from samcli.commands.package.command import do_cli
4+
from samcli.commands.package.command import do_cli, resources_and_properties_help_string
5+
from samcli.commands.package.exceptions import PackageResolveS3AndS3NotSetError
56

67

78
class TestPackageCliCommand(TestCase):
@@ -20,6 +21,7 @@ def setUp(self):
2021
self.region = None
2122
self.profile = None
2223
self.resolve_s3 = False
24+
self.resolve_image_repos = False
2325
self.signing_profiles = {"MyFunction": {"profile_name": "ProfileName", "profile_owner": "Profile Owner"}}
2426

2527
@patch("samcli.commands.package.command.click")
@@ -43,6 +45,7 @@ def test_all_args(self, package_command_context, click_mock):
4345
region=self.region,
4446
profile=self.profile,
4547
resolve_s3=self.resolve_s3,
48+
resolve_image_repos=self.resolve_image_repos,
4649
signing_profiles=self.signing_profiles,
4750
)
4851

@@ -61,6 +64,7 @@ def test_all_args(self, package_command_context, click_mock):
6164
region=self.region,
6265
profile=self.profile,
6366
signing_profiles=self.signing_profiles,
67+
resolve_image_repos=self.resolve_image_repos,
6468
)
6569

6670
context_mock.run.assert_called_with()
@@ -89,6 +93,7 @@ def test_all_args_resolve_s3(self, mock_managed_stack, package_command_context,
8993
region=self.region,
9094
profile=self.profile,
9195
resolve_s3=True,
96+
resolve_image_repos=False,
9297
signing_profiles=self.signing_profiles,
9398
)
9499

@@ -107,7 +112,84 @@ def test_all_args_resolve_s3(self, mock_managed_stack, package_command_context,
107112
region=self.region,
108113
profile=self.profile,
109114
signing_profiles=self.signing_profiles,
115+
resolve_image_repos=False,
110116
)
111117

112118
context_mock.run.assert_called_with()
113119
self.assertEqual(context_mock.run.call_count, 1)
120+
121+
@patch("samcli.commands.package.command.click")
122+
@patch("samcli.commands.package.package_context.PackageContext")
123+
def test_resolve_image_repos_without_s3_bucket_raises_error(self, package_command_context, click_mock):
124+
with self.assertRaises(PackageResolveS3AndS3NotSetError):
125+
do_cli(
126+
template_file=self.template_file,
127+
s3_bucket=None,
128+
s3_prefix=self.s3_prefix,
129+
image_repository=None,
130+
image_repositories=None,
131+
kms_key_id=self.kms_key_id,
132+
output_template_file=self.output_template_file,
133+
use_json=self.use_json,
134+
force_upload=self.force_upload,
135+
no_progressbar=self.no_progressbar,
136+
metadata=self.metadata,
137+
region=self.region,
138+
profile=self.profile,
139+
resolve_s3=False,
140+
resolve_image_repos=True,
141+
signing_profiles=self.signing_profiles,
142+
)
143+
144+
@patch("samcli.commands.package.command.click")
145+
@patch("samcli.commands.package.package_context.PackageContext")
146+
def test_all_args_with_resolve_image_repos(self, package_command_context, click_mock):
147+
context_mock = Mock()
148+
package_command_context.return_value.__enter__.return_value = context_mock
149+
150+
do_cli(
151+
template_file=self.template_file,
152+
s3_bucket=self.s3_bucket,
153+
s3_prefix=self.s3_prefix,
154+
image_repository=None,
155+
image_repositories=None,
156+
kms_key_id=self.kms_key_id,
157+
output_template_file=self.output_template_file,
158+
use_json=self.use_json,
159+
force_upload=self.force_upload,
160+
no_progressbar=self.no_progressbar,
161+
metadata=self.metadata,
162+
region=self.region,
163+
profile=self.profile,
164+
resolve_s3=False,
165+
resolve_image_repos=True,
166+
signing_profiles=self.signing_profiles,
167+
)
168+
169+
package_command_context.assert_called_with(
170+
template_file=self.template_file,
171+
s3_bucket=self.s3_bucket,
172+
s3_prefix=self.s3_prefix,
173+
image_repository=None,
174+
image_repositories=None,
175+
kms_key_id=self.kms_key_id,
176+
output_template_file=self.output_template_file,
177+
use_json=self.use_json,
178+
force_upload=self.force_upload,
179+
no_progressbar=self.no_progressbar,
180+
metadata=self.metadata,
181+
region=self.region,
182+
profile=self.profile,
183+
signing_profiles=self.signing_profiles,
184+
resolve_image_repos=True,
185+
)
186+
187+
context_mock.run.assert_called_with()
188+
self.assertEqual(context_mock.run.call_count, 1)
189+
190+
def test_resources_and_properties_help_string(self):
191+
# Test that the help string generator works
192+
help_string = resources_and_properties_help_string()
193+
self.assertIsInstance(help_string, str)
194+
# Should contain resource and location information
195+
self.assertTrue(len(help_string) > 0)

0 commit comments

Comments
 (0)