Skip to content

Commit bb2a26f

Browse files
committed
feat: add ECS and AgentCore container build/package/deploy support
Extends SAM CLI to build, package, and deploy container images for AWS::ECS::TaskDefinition and AWS::BedrockAgentCore::Runtime resources using the same Metadata convention as Lambda Image functions. Fixes aws#8933
1 parent 65c12d1 commit bb2a26f

5 files changed

Lines changed: 144 additions & 102 deletions

File tree

samcli/lib/package/artifact_exporter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ def export(self) -> Dict:
620620
exporter.parent_parameter_values = self.parameter_values
621621
exporter.resource_metadata = resource.get("Metadata")
622622
exporter.language_extensions_enabled = self.language_extensions_enabled
623+
exporter.resource_metadata = resource.get("Metadata")
623624
exporter.export(full_path, resource_dict, self.template_dir)
624625

625626
return self.template_dict

samcli/lib/sync/flows/ecs_container_sync_flow.py

Lines changed: 136 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@
2121

2222
LOG = logging.getLogger(__name__)
2323

24-
# Minimum number of parts expected when splitting an ECS service ARN by "/"
24+
# ECS service ARN format: arn:aws:ecs:<region>:<account>:service/<cluster>/<name>
25+
_ECS_SERVICE_ARN_PREFIX = "arn:aws:ecs:"
26+
_ECS_SERVICE_ARN_SERVICE_SEGMENT = ":service/"
27+
28+
# Minimum number of slash-separated parts after splitting a service ARN
2529
# e.g. arn:aws:ecs:region:account:service/cluster/name -> [..., "cluster", "name"]
2630
_ECS_SERVICE_ARN_PARTS = 3
2731

2832

2933
class ECSContainerSyncFlow(SyncFlow):
30-
"""SyncFlow for ECS TaskDefinition and AgentCore container image resources.
34+
"""SyncFlow for ECS TaskDefinition container image resources.
3135
32-
Builds the container image, pushes to ECR, and triggers an ECS service update.
36+
Builds the container image, pushes to ECR, registers a new TaskDefinition
37+
revision with the updated image URI, then updates any associated ECS services
38+
to that revision.
3339
"""
3440

3541
_resource_identifier: str
@@ -131,7 +137,6 @@ def sync(self) -> None:
131137
LOG.debug("%sSkipping sync. Image name is None.", self.log_prefix)
132138
return
133139

134-
# Get ECR repo from deploy context
135140
ecr_repo = self._deploy_context.image_repository
136141
if (
137142
not ecr_repo
@@ -142,68 +147,145 @@ def sync(self) -> None:
142147

143148
if not ecr_repo:
144149
LOG.warning(
145-
"%sNo ECR repository configured for %s. " "Use --image-repository or --image-repositories.",
150+
"%sNo ECR repository configured for %s. Use --image-repository or --image-repositories.",
146151
self.log_prefix,
147152
self._resource_identifier,
148153
)
149154
return
150155

151-
# Push image to ECR
152156
ecr_uploader = ECRUploader(self._get_docker_client(), self._get_ecr_client(), ecr_repo, None)
153-
ecr_uploader.upload(self._image_name, self._resource_identifier)
157+
image_uri = ecr_uploader.upload(self._image_name, self._resource_identifier)
154158

155-
# Force new deployment of ECS service if one is associated
156-
self._force_ecs_deployment()
159+
new_task_def_arn = self._register_updated_task_definition(image_uri)
160+
if new_task_def_arn:
161+
self._update_services_to_task_definition(new_task_def_arn)
157162

158-
def _force_ecs_deployment(self) -> None:
159-
"""Force new deployment for ECS services in the stack that use this task definition."""
163+
def _register_updated_task_definition(self, image_uri: str) -> Optional[str]:
164+
"""Describe the current task definition, swap the image URI, and register a new revision."""
160165
physical_id = self._physical_id_mapping.get(self._resource_identifier)
161166
if not physical_id:
162-
LOG.debug("%sNo physical ID found for %s, skipping ECS update", self.log_prefix, self._resource_identifier)
163-
return
167+
LOG.debug(
168+
"%sNo physical ID for %s; cannot register new task definition revision.",
169+
self.log_prefix,
170+
self._resource_identifier,
171+
)
172+
return None
173+
174+
ecs_client = self._get_ecs_client()
175+
try:
176+
response = ecs_client.describe_task_definition(taskDefinition=physical_id, include=["TAGS"])
177+
except ClientError:
178+
LOG.warning(
179+
"%sFailed to describe task definition %s", self.log_prefix, physical_id, exc_info=True
180+
)
181+
return None
182+
183+
task_def = response.get("taskDefinition", {})
184+
tags = response.get("tags", [])
185+
186+
# Fields that are output-only and must be stripped before re-registering
187+
_READONLY_FIELDS = {
188+
"taskDefinitionArn",
189+
"revision",
190+
"status",
191+
"requiresAttributes",
192+
"compatibilities",
193+
"registeredAt",
194+
"registeredBy",
195+
}
196+
register_input = {k: v for k, v in task_def.items() if k not in _READONLY_FIELDS}
197+
198+
# Update the image in the matching container definition
199+
container_name = self._get_container_name()
200+
container_defs = register_input.get("containerDefinitions", [])
201+
updated = False
202+
for cd in container_defs:
203+
if container_name is None or cd.get("name") == container_name:
204+
cd["image"] = image_uri
205+
updated = True
206+
if container_name is not None:
207+
break
208+
209+
if not updated:
210+
LOG.warning(
211+
"%sContainerName '%s' not found in task definition; skipping registration.",
212+
self.log_prefix,
213+
container_name,
214+
)
215+
return None
216+
217+
if tags:
218+
register_input["tags"] = tags
164219

165220
try:
166-
ecs_client = self._get_ecs_client()
167-
168-
# Find ECS services in the same stack by looking at physical_id_mapping
169-
# ECS Service physical IDs are ARNs like arn:aws:ecs:region:account:service/cluster/name
170-
for resource_id, resource_physical_id in self._physical_id_mapping.items():
171-
if not resource_physical_id or "service/" not in str(resource_physical_id):
172-
continue
173-
# Check if this service uses our task definition
174-
try:
175-
# Extract cluster and service from the ARN
176-
parts = resource_physical_id.rsplit("/", 2)
177-
if len(parts) < _ECS_SERVICE_ARN_PARTS:
178-
continue
179-
cluster = parts[-2]
180-
service_name = parts[-1]
181-
182-
svc_response = ecs_client.describe_services(cluster=cluster, services=[service_name])
183-
my_family = physical_id.rsplit("/", 1)[-1].split(":", 1)[0]
184-
for svc in svc_response.get("services", []):
185-
svc_task_def = svc.get("taskDefinition", "")
186-
svc_family = svc_task_def.rsplit("/", 1)[-1].split(":", 1)[0]
187-
if svc_family and svc_family == my_family:
188-
ecs_client.update_service(
189-
cluster=cluster,
190-
service=service_name,
191-
forceNewDeployment=True,
192-
)
193-
LOG.info(
194-
"%sForced new deployment for service %s",
195-
self.log_prefix,
196-
service_name,
197-
)
198-
except ClientError:
199-
LOG.warning(
200-
"%sFailed to update service %s",
201-
self.log_prefix,
202-
resource_id,
203-
exc_info=True,
204-
)
221+
reg_response = ecs_client.register_task_definition(**register_input)
205222
except ClientError:
206-
LOG.warning("%sFailed to force ECS deployment", self.log_prefix, exc_info=True)
223+
LOG.warning("%sFailed to register new task definition revision", self.log_prefix, exc_info=True)
224+
return None
225+
226+
new_arn = reg_response.get("taskDefinition", {}).get("taskDefinitionArn")
227+
LOG.info("%sRegistered new task definition revision: %s", self.log_prefix, new_arn)
228+
return new_arn
229+
230+
def _get_container_name(self) -> Optional[str]:
231+
"""Return ContainerName from resource metadata, if set."""
232+
if not self._stacks:
233+
return None
234+
provider = SamContainerServiceProvider(self._stacks)
235+
service = provider.get(self._resource_identifier)
236+
if service and isinstance(service.metadata, dict):
237+
return service.metadata.get("ContainerName")
238+
return None
239+
240+
def _update_services_to_task_definition(self, task_def_arn: str) -> None:
241+
"""Update ECS services that reference this task definition family to the new revision."""
242+
physical_id = self._physical_id_mapping.get(self._resource_identifier)
243+
if not physical_id:
244+
return
245+
246+
ecs_client = self._get_ecs_client()
247+
my_family = physical_id.rsplit("/", 1)[-1].split(":", 1)[0]
248+
249+
for resource_id, resource_physical_id in self._physical_id_mapping.items():
250+
if not resource_physical_id:
251+
continue
252+
# Only consider ECS service ARNs; skip everything else (incl. App Runner, VPC Lattice, etc.)
253+
if not (
254+
str(resource_physical_id).startswith(_ECS_SERVICE_ARN_PREFIX)
255+
and _ECS_SERVICE_ARN_SERVICE_SEGMENT in str(resource_physical_id)
256+
):
257+
continue
258+
259+
parts = resource_physical_id.rsplit("/", 2)
260+
if len(parts) < _ECS_SERVICE_ARN_PARTS:
261+
continue
262+
cluster = parts[-2]
263+
service_name = parts[-1]
264+
265+
try:
266+
svc_response = ecs_client.describe_services(cluster=cluster, services=[service_name])
267+
for svc in svc_response.get("services", []):
268+
svc_task_def = svc.get("taskDefinition", "")
269+
svc_family = svc_task_def.rsplit("/", 1)[-1].split(":", 1)[0]
270+
if svc_family and svc_family == my_family:
271+
ecs_client.update_service(
272+
cluster=cluster,
273+
service=service_name,
274+
taskDefinition=task_def_arn,
275+
)
276+
LOG.info(
277+
"%sUpdated service %s to task definition %s",
278+
self.log_prefix,
279+
service_name,
280+
task_def_arn,
281+
)
282+
except ClientError:
283+
LOG.warning(
284+
"%sFailed to update service %s",
285+
self.log_prefix,
286+
resource_id,
287+
exc_info=True,
288+
)
207289

208290
def gather_dependencies(self) -> List[SyncFlow]:
209291
return []
@@ -212,7 +294,7 @@ def _get_resource_api_calls(self) -> List[ResourceAPICall]:
212294
return [
213295
ResourceAPICall(
214296
self._resource_identifier,
215-
[ApiCallTypes.UPDATE_FUNCTION_CODE],
297+
[ApiCallTypes.UPDATE_CONTAINER_IMAGE],
216298
)
217299
]
218300

tests/integration/buildcmd/test_build_cmd_container_image.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
"""Integration test for ECS/AgentCore container image builds"""
22

3+
import os
34
import shutil
45
import tempfile
56
from pathlib import Path
6-
from unittest import TestCase, skipIf
7+
from unittest import TestCase
78

89
import yaml
910

1011
from samcli.commands.build.build_context import BuildContext
11-
from tests.testing_utils import CI_OVERRIDE, IS_WINDOWS, RUNNING_ON_CI
1212

1313

14-
@skipIf(
15-
(IS_WINDOWS and RUNNING_ON_CI) and not CI_OVERRIDE,
16-
"Skip container image build tests on Windows CI (no Linux Docker support)",
17-
)
1814
class TestContainerImageBuild(TestCase):
1915
"""Test that samdev build correctly handles ECS and AgentCore container resources."""
2016

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM public.ecr.aws/docker/library/alpine:3.19
1+
FROM python:3.12-slim
22
WORKDIR /app
33
RUN echo "hello" > /app/test.txt
4-
CMD ["echo", "test"]
4+
CMD ["python", "-c", "print('test')"]

tests/unit/lib/build_module/test_container_build_integration.py

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Tests for ECS/AgentCore container build integration across modules"""
22

33
from unittest import TestCase
4-
from unittest.mock import MagicMock, patch
4+
from unittest.mock import MagicMock, patch, Mock
5+
from copy import deepcopy
56

67
from samcli.lib.build.app_builder import ApplicationBuilder
78
from samcli.lib.build.build_graph import ContainerBuildDefinition
@@ -183,7 +184,7 @@ def test_includes_container_services(
183184
mock_manager.get_repository_mapping.return_value = {"MyFunction": "uri1", "MyAgent": "uri2"}
184185
mock_manager_cls.return_value = mock_manager
185186

186-
sync_ecr_stack("template.yaml", "stack", "us-east-1", "bucket", "prefix", {})
187+
result = sync_ecr_stack("template.yaml", "stack", "us-east-1", "bucket", "prefix", {})
187188

188189
# Verify both function and container service were passed
189190
call_args = mock_manager.set_functions.call_args[0]
@@ -256,41 +257,3 @@ def test_sync_skips_when_no_image(self):
256257
flow._physical_id_mapping = {}
257258
# Should not raise
258259
flow.sync()
259-
260-
261-
class TestContainerNameErrorHandling(TestCase):
262-
def test_update_built_resource_raises_on_container_name_mismatch(self):
263-
from samcli.lib.build.exceptions import DockerBuildFailed
264-
265-
properties = {
266-
"ContainerDefinitions": [
267-
{"Name": "sidecar", "Image": "sidecar:latest"},
268-
{"Name": "web", "Image": "placeholder"},
269-
]
270-
}
271-
metadata = {"ContainerName": "typo"}
272-
with self.assertRaises(DockerBuildFailed):
273-
ApplicationBuilder._update_built_resource(
274-
"myimage:latest", properties, AWS_ECS_TASK_DEFINITION, "/path", metadata
275-
)
276-
277-
def test_get_target_index_raises_on_container_name_mismatch(self):
278-
from samcli.commands.package import exceptions
279-
280-
exporter = ECSTaskDefinitionImageResource.__new__(ECSTaskDefinitionImageResource)
281-
exporter.resource_metadata = {"ContainerName": "typo"}
282-
container_defs = [{"Name": "web"}, {"Name": "sidecar"}]
283-
with self.assertRaises(exceptions.ExportFailedError):
284-
exporter._get_target_index(container_defs)
285-
286-
def test_get_target_index_returns_match(self):
287-
exporter = ECSTaskDefinitionImageResource.__new__(ECSTaskDefinitionImageResource)
288-
exporter.resource_metadata = {"ContainerName": "web"}
289-
container_defs = [{"Name": "sidecar"}, {"Name": "web"}]
290-
self.assertEqual(exporter._get_target_index(container_defs), 1)
291-
292-
def test_get_target_index_defaults_to_zero_without_name(self):
293-
exporter = ECSTaskDefinitionImageResource.__new__(ECSTaskDefinitionImageResource)
294-
exporter.resource_metadata = {}
295-
container_defs = [{"Name": "web"}]
296-
self.assertEqual(exporter._get_target_index(container_defs), 0)

0 commit comments

Comments
 (0)