Skip to content

Commit fcb2de6

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. ## Changes - Add resource type constants and RESOURCES_WITH_IMAGE_COMPONENT entries - Add ContainerBuildDefinition to build graph with Architecture support - Add build_container_images() to ApplicationBuilder - Create packageable resource classes for ECR push during package/deploy - Create SamContainerServiceProvider to discover buildable resources - Wire container builds into BuildContext.run() - Create ECSContainerSyncFlow for sam sync support - Register in SyncFlowFactory - Extend sync_ecr_stack for auto ECR repo creation (--resolve-image-repos) - Support ContainerName metadata for multi-container ECS TaskDefinitions - Update sam build help text ## Template Usage ```yaml Resources: MyAgent: Type: AWS::BedrockAgentCore::Runtime Metadata: Dockerfile: Dockerfile DockerContext: ./agent DockerTag: latest Architecture: arm64 Properties: AgentRuntimeArtifact: ContainerConfiguration: ContainerUri: placeholder MyTask: Type: AWS::ECS::TaskDefinition Metadata: Dockerfile: Dockerfile DockerContext: ./app ContainerName: web # targets specific container Properties: ContainerDefinitions: - Name: web Image: placeholder ``` ## Testing - 51 new unit tests (853 total passing) - Integration test with Docker build - End-to-end validated: build → ECR push → CloudFormation deploy
1 parent 2be25b7 commit fcb2de6

16 files changed

Lines changed: 1217 additions & 4 deletions

File tree

samcli/commands/build/build_context.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
BuildError,
2727
UnsupportedBuilderLibraryVersionError,
2828
)
29-
from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR
29+
from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR, ContainerBuildDefinition
3030
from samcli.lib.build.bundler import EsbuildBundlerManager
3131
from samcli.lib.build.exceptions import (
3232
BuildInsideContainerError,
@@ -38,6 +38,7 @@
3838
from samcli.lib.providers.sam_api_provider import SamApiProvider
3939
from samcli.lib.providers.sam_function_provider import SamFunctionProvider
4040
from samcli.lib.providers.sam_layer_provider import SamLayerProvider
41+
from samcli.lib.providers.sam_container_provider import SamContainerServiceProvider
4142
from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider
4243
from samcli.lib.telemetry.event import EventName, EventTracker, UsedFeature
4344
from samcli.lib.utils.osutils import BUILD_DIR_PERMISSIONS
@@ -293,6 +294,11 @@ def run(self) -> None:
293294

294295
self._build_result = builder.build()
295296

297+
# Build container images for ECS/AgentCore resources
298+
container_artifacts = self._build_container_images(builder)
299+
if container_artifacts:
300+
self._build_result.artifacts.update(container_artifacts)
301+
296302
self._handle_build_post_processing(builder, self._build_result)
297303

298304
click.secho("\nBuild Succeeded", fg="green")
@@ -623,6 +629,33 @@ def collect_all_build_resources(self) -> ResourcesToBuildCollector:
623629
)
624630
return result
625631

632+
def _build_container_images(self, builder: ApplicationBuilder) -> Dict[str, str]:
633+
"""
634+
Discover and build container images for ECS/AgentCore resources.
635+
636+
Returns
637+
-------
638+
Dict[str, str]
639+
Map of resource full_path to built image tag
640+
"""
641+
container_provider = SamContainerServiceProvider(self.stacks)
642+
container_services = list(container_provider.get_all())
643+
if not container_services:
644+
return {}
645+
646+
LOG.info("Found %d container service resource(s) to build", len(container_services))
647+
648+
container_build_defs = []
649+
for service in container_services:
650+
build_def = ContainerBuildDefinition(
651+
resource_identifier=service.full_path,
652+
resource_type=service.resource_type,
653+
metadata=service.metadata,
654+
)
655+
container_build_defs.append(build_def)
656+
657+
return builder.build_container_images(container_build_defs)
658+
626659
@property
627660
def is_building_specific_resource(self) -> bool:
628661
"""

samcli/commands/build/command.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
2. AWS::Lambda::Function\n
5555
3. AWS::Serverless::LayerVersion\n
5656
4. AWS::Lambda::LayerVersion\n
57+
5. AWS::ECS::TaskDefinition (container image)\n
58+
6. AWS::BedrockAgentCore::Runtime (container image)\n
5759
\b
5860
Supported Runtimes
5961
------------------

samcli/lib/bootstrap/companion_stack/companion_stack_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,14 @@ def sync_ecr_stack(
312312
function_logical_ids = [
313313
function.full_path for function in function_provider.get_all() if function.packagetype == IMAGE
314314
]
315+
316+
# Also include ECS/AgentCore container resources that need ECR repos
317+
from samcli.lib.providers.sam_container_provider import SamContainerServiceProvider
318+
319+
container_provider = SamContainerServiceProvider(stacks)
320+
container_logical_ids = [service.full_path for service in container_provider.get_all()]
321+
function_logical_ids.extend(container_logical_ids)
322+
315323
manager.set_functions(function_logical_ids, image_repositories)
316324
image_repositories.update(manager.get_repository_mapping())
317325
manager.sync_repos()

samcli/lib/build/app_builder.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from aws_lambda_builders.exceptions import LambdaBuilderError
2020

2121
from samcli.commands._utils.experimental import get_enabled_experimental_flags
22-
from samcli.lib.build.build_graph import BuildGraph, FunctionBuildDefinition, LayerBuildDefinition
22+
from samcli.lib.build.build_graph import BuildGraph, ContainerBuildDefinition, FunctionBuildDefinition, LayerBuildDefinition
2323
from samcli.lib.build.build_strategy import (
2424
BuildStrategy,
2525
CachedOrIncrementalBuildStrategyWrapper,
@@ -51,7 +51,9 @@
5151
from samcli.lib.utils.packagetype import IMAGE, ZIP
5252
from samcli.lib.utils.path_utils import check_path_valid_type, convert_path_to_unix_path
5353
from samcli.lib.utils.resources import (
54+
AWS_BEDROCK_AGENTCORE_RUNTIME,
5455
AWS_CLOUDFORMATION_STACK,
56+
AWS_ECS_TASK_DEFINITION,
5557
AWS_LAMBDA_FUNCTION,
5658
AWS_LAMBDA_LAYERVERSION,
5759
AWS_SERVERLESS_APPLICATION,
@@ -378,7 +380,8 @@ def update_template(
378380

379381
if has_build_artifact:
380382
ApplicationBuilder._update_built_resource(
381-
built_artifacts[full_path], properties, resource_type, store_path
383+
built_artifacts[full_path], properties, resource_type, store_path,
384+
resource.get("Metadata"),
382385
)
383386

384387
if is_stack:
@@ -391,7 +394,9 @@ def update_template(
391394
return template_dict
392395

393396
@staticmethod
394-
def _update_built_resource(path: str, resource_properties: Dict, resource_type: str, absolute_path: str) -> None:
397+
def _update_built_resource(
398+
path: str, resource_properties: Dict, resource_type: str, absolute_path: str, metadata: Optional[Dict] = None
399+
) -> None:
395400
if resource_type == AWS_SERVERLESS_FUNCTION and resource_properties.get("PackageType", ZIP) == ZIP:
396401
resource_properties["CodeUri"] = absolute_path
397402
if resource_type == AWS_LAMBDA_FUNCTION and resource_properties.get("PackageType", ZIP) == ZIP:
@@ -404,6 +409,21 @@ def _update_built_resource(path: str, resource_properties: Dict, resource_type:
404409
resource_properties["Code"] = {"ImageUri": path}
405410
if resource_type == AWS_SERVERLESS_FUNCTION and resource_properties.get("PackageType", ZIP) == IMAGE:
406411
resource_properties["ImageUri"] = path
412+
if resource_type == AWS_ECS_TASK_DEFINITION:
413+
container_defs = resource_properties.get("ContainerDefinitions", [])
414+
if container_defs:
415+
target_name = metadata.get("ContainerName") if metadata else None
416+
if target_name:
417+
for container_def in container_defs:
418+
if container_def.get("Name") == target_name:
419+
container_def["Image"] = path
420+
break
421+
else:
422+
container_defs[0]["Image"] = path
423+
if resource_type == AWS_BEDROCK_AGENTCORE_RUNTIME:
424+
artifact = resource_properties.setdefault("AgentRuntimeArtifact", {})
425+
container_config = artifact.setdefault("ContainerConfiguration", {})
426+
container_config["ContainerUri"] = path
407427

408428
def _build_lambda_image(self, function_name: str, metadata: Dict, architecture: str) -> str:
409429
"""
@@ -538,6 +558,53 @@ def _load_lambda_image(self, image_archive_path: str) -> str:
538558
except (docker.errors.APIError, OSError, ContainerArchiveImageLoadFailedException) as ex:
539559
raise DockerBuildFailed(msg=str(ex)) from ex
540560

561+
def build_container_images(self, container_build_definitions: List[ContainerBuildDefinition]) -> Dict[str, str]:
562+
"""
563+
Build container images for non-Lambda resources (ECS, AgentCore).
564+
565+
Parameters
566+
----------
567+
container_build_definitions : List[ContainerBuildDefinition]
568+
List of container build definitions to build
569+
570+
Returns
571+
-------
572+
Dict[str, str]
573+
Map of resource identifier to the built image tag
574+
"""
575+
artifacts: Dict[str, str] = {}
576+
for container_def in container_build_definitions:
577+
if not container_def.metadata:
578+
continue
579+
image_tag = self._build_container_image(
580+
container_def.resource_identifier,
581+
container_def.metadata,
582+
container_def.architecture,
583+
)
584+
artifacts[container_def.resource_identifier] = image_tag
585+
return artifacts
586+
587+
def _build_container_image(self, resource_name: str, metadata: Dict, architecture: str) -> str:
588+
"""
589+
Build a container image for an ECS/AgentCore resource. Uses the same Metadata
590+
convention as Lambda Image builds (Dockerfile, DockerContext, DockerBuildArgs, etc.)
591+
592+
Parameters
593+
----------
594+
resource_name : str
595+
Logical ID or identifier of the resource
596+
metadata : dict
597+
Metadata block from the resource
598+
architecture : str
599+
Target architecture
600+
601+
Returns
602+
-------
603+
str
604+
The full tag of the built image
605+
"""
606+
return self._build_lambda_image(resource_name, metadata, architecture)
607+
541608
def _build_layer(
542609
self,
543610
layer_name: str,

samcli/lib/build/build_graph.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,14 @@ class BuildGraph:
200200
# global table build definitions key
201201
FUNCTION_BUILD_DEFINITIONS = "function_build_definitions"
202202
LAYER_BUILD_DEFINITIONS = "layer_build_definitions"
203+
CONTAINER_BUILD_DEFINITIONS = "container_build_definitions"
203204

204205
def __init__(self, build_dir: str) -> None:
205206
# put build.toml file inside .aws-sam folder
206207
self._filepath = Path(build_dir).parent.joinpath(DEFAULT_BUILD_GRAPH_FILE_NAME)
207208
self._function_build_definitions: List["FunctionBuildDefinition"] = []
208209
self._layer_build_definitions: List["LayerBuildDefinition"] = []
210+
self._container_build_definitions: List["ContainerBuildDefinition"] = []
209211
self._atomic_read()
210212

211213
def get_function_build_definitions(self) -> Tuple["FunctionBuildDefinition", ...]:
@@ -214,6 +216,22 @@ def get_function_build_definitions(self) -> Tuple["FunctionBuildDefinition", ...
214216
def get_layer_build_definitions(self) -> Tuple["LayerBuildDefinition", ...]:
215217
return tuple(self._layer_build_definitions)
216218

219+
def get_container_build_definitions(self) -> Tuple["ContainerBuildDefinition", ...]:
220+
return tuple(self._container_build_definitions)
221+
222+
def put_container_build_definition(self, container_build_definition: "ContainerBuildDefinition") -> None:
223+
"""
224+
Puts the newly read container build definition into existing build graph.
225+
Each container build definition is unique per resource identifier.
226+
"""
227+
if container_build_definition not in self._container_build_definitions:
228+
LOG.debug(
229+
"Adding container build definition: %s",
230+
container_build_definition,
231+
)
232+
self._container_build_definitions.append(container_build_definition)
233+
234+
217235
def get_function_build_definition_with_full_path(
218236
self, function_full_path: str
219237
) -> Optional["FunctionBuildDefinition"]:
@@ -701,3 +719,67 @@ def __eq__(self, other: Any) -> bool:
701719
and self.env_vars == other.env_vars
702720
and self.architecture == other.architecture
703721
)
722+
723+
724+
class ContainerBuildDefinition(AbstractBuildDefinition):
725+
"""
726+
ContainerBuildDefinition holds information about each unique container image build
727+
for non-Lambda resources (ECS TaskDefinitions, AgentCore, etc.)
728+
"""
729+
730+
def __init__(
731+
self,
732+
resource_identifier: str,
733+
resource_type: str,
734+
metadata: Optional[Dict],
735+
source_hash: str = "",
736+
manifest_hash: str = "",
737+
env_vars: Optional[Dict] = None,
738+
architecture: str = X86_64,
739+
) -> None:
740+
# Allow metadata to override architecture (e.g., "arm64" for AgentCore)
741+
if metadata and metadata.get("Architecture"):
742+
architecture = metadata["Architecture"]
743+
super().__init__(source_hash, manifest_hash, env_vars, architecture)
744+
self.resource_identifier = resource_identifier
745+
self.resource_type = resource_type
746+
747+
metadata_copied = deepcopy(metadata) if metadata else {}
748+
metadata_copied.pop(SAM_RESOURCE_ID_KEY, "")
749+
metadata_copied.pop(SAM_IS_NORMALIZED, "")
750+
self.metadata = metadata_copied
751+
752+
@property
753+
def dockerfile(self) -> Optional[str]:
754+
return self.metadata.get("Dockerfile") if self.metadata else None
755+
756+
@property
757+
def docker_context(self) -> Optional[str]:
758+
return self.metadata.get("DockerContext") if self.metadata else None
759+
760+
@property
761+
def docker_build_args(self) -> Dict:
762+
return self.metadata.get("DockerBuildArgs", {}) if self.metadata else {}
763+
764+
@property
765+
def docker_build_target(self) -> Optional[str]:
766+
return self.metadata.get("DockerBuildTarget") if self.metadata else None
767+
768+
def get_resource_full_paths(self) -> str:
769+
return self.resource_identifier
770+
771+
def __str__(self) -> str:
772+
return (
773+
f"ContainerBuildDefinition({self.resource_identifier}, {self.resource_type}, "
774+
f"{self.source_hash}, {self.uuid}, {self.metadata}, {self.architecture})"
775+
)
776+
777+
def __eq__(self, other: Any) -> bool:
778+
if not isinstance(other, ContainerBuildDefinition):
779+
return False
780+
return (
781+
self.resource_identifier == other.resource_identifier
782+
and self.resource_type == other.resource_type
783+
and self.metadata == other.metadata
784+
and self.architecture == other.architecture
785+
)

0 commit comments

Comments
 (0)