From 2eeb2b3e3f4aa7743da7eeed506c67c4829e61c1 Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Fri, 15 May 2026 10:15:11 +0300 Subject: [PATCH 1/6] 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 #8933 --- PR_DESCRIPTION.md | 68 +++++ .../container_image_builds_ecs_agentcore.md | 234 ++++++++++++++++ samcli/commands/build/build_context.py | 35 ++- samcli/commands/build/command.py | 2 + .../companion_stack_manager.py | 8 + samcli/lib/build/app_builder.py | 86 +++++- samcli/lib/build/build_graph.py | 81 ++++++ samcli/lib/package/artifact_exporter.py | 2 + samcli/lib/package/packageable_resources.py | 97 +++++++ .../lib/providers/sam_container_provider.py | 100 +++++++ .../lib/sync/flows/ecs_container_sync_flow.py | 222 +++++++++++++++ samcli/lib/sync/sync_flow_factory.py | 20 ++ samcli/lib/utils/resources.py | 10 + .../test_build_cmd_container_image.py | 63 +++++ .../buildcmd/container_image/agent/Dockerfile | 4 + .../buildcmd/container_image/template.yaml | 33 +++ .../test_container_build_definition.py | 103 +++++++ .../test_container_build_integration.py | 259 ++++++++++++++++++ .../providers/test_sam_container_provider.py | 141 ++++++++++ 19 files changed, 1564 insertions(+), 4 deletions(-) create mode 100644 PR_DESCRIPTION.md create mode 100644 designs/container_image_builds_ecs_agentcore.md create mode 100644 samcli/lib/providers/sam_container_provider.py create mode 100644 samcli/lib/sync/flows/ecs_container_sync_flow.py create mode 100644 tests/integration/buildcmd/test_build_cmd_container_image.py create mode 100644 tests/integration/testdata/buildcmd/container_image/agent/Dockerfile create mode 100644 tests/integration/testdata/buildcmd/container_image/template.yaml create mode 100644 tests/unit/lib/build_module/test_container_build_definition.py create mode 100644 tests/unit/lib/build_module/test_container_build_integration.py create mode 100644 tests/unit/lib/providers/test_sam_container_provider.py diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000000..2afc1823192 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,68 @@ +#### Which issue(s) does this change fix? + +Fixes #8933 + +#### Why is this change necessary? + +SAM CLI provides an excellent developer experience for Lambda Image functions (`sam build && sam deploy`), but users deploying containerized workloads to ECS (Fargate) or Bedrock AgentCore must manage their Docker build/push/deploy pipeline separately — even when these resources live in the same CloudFormation template. This creates a fragmented workflow requiring external tooling for an identical operation: build image → push to ECR → deploy. + +#### How does it address the issue? + +Extends the existing Lambda Image build pipeline to recognize `AWS::ECS::TaskDefinition` and `AWS::BedrockAgentCore::Runtime` resources with a `Metadata` block containing `Dockerfile` and `DockerContext`. No new commands — `sam build`, `sam package`, `sam deploy`, and `sam sync` gain awareness of these resource types. + +**Template example:** +```yaml +Resources: + MyAgent: + Type: AWS::BedrockAgentCore::Runtime + Metadata: + Dockerfile: Dockerfile + DockerContext: ./agent + Architecture: arm64 + Properties: + AgentRuntimeArtifact: + ContainerConfiguration: + ContainerUri: placeholder + + MyTask: + Type: AWS::ECS::TaskDefinition + Metadata: + Dockerfile: Dockerfile + DockerContext: ./app + ContainerName: web + Properties: + ContainerDefinitions: + - Name: web + Image: placeholder +``` + +**Key implementation details:** +- Reuses `_build_lambda_image()` — same Docker build logic, buildkit support included +- `--resolve-image-repos` auto-creates ECR repos via companion stack +- `ContainerName` metadata targets specific containers in multi-container TaskDefinitions +- `Architecture` metadata sets `--platform` (e.g., `arm64` for AgentCore) +- `ARTIFACT_TYPE = ZIP` to pass the `PackageType` filter (these resources don't have `PackageType`) +- No SAM Transform changes needed — uses native CloudFormation resource types + +**Design document:** `designs/container_image_builds_ecs_agentcore.md` + +#### What side effects does this change have? + +- `sam build` logs "Found N container service resource(s) to build" when applicable resources are present. No behavior change for templates without these resources. +- `--resolve-image-repos` creates ECR repos for ECS/AgentCore in addition to Lambda Image functions. +- `_update_built_resource` adds an optional `metadata` parameter (backward compatible, defaults to `None`). + +#### Mandatory Checklist +**PRs will only be reviewed after checklist is complete** + +- [x] Review the [generative AI contribution guidelines](https://github.com/aws/aws-sam-cli/blob/develop/CONTRIBUTING.md#ai-usage) +- [x] Add input/output [type hints](https://docs.python.org/3/library/typing.html) to new functions/methods +- [x] Write design document if needed ([Do I need to write a design document?](https://github.com/aws/aws-sam-cli/blob/develop/DEVELOPMENT_GUIDE.md#design-document)) +- [x] Write/update unit tests +- [x] Write/update integration tests +- [x] Write/update functional tests if needed +- [x] `make pr` passes +- [x] `make update-reproducible-reqs` if dependencies were changed +- [ ] Write documentation + +By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/designs/container_image_builds_ecs_agentcore.md b/designs/container_image_builds_ecs_agentcore.md new file mode 100644 index 00000000000..1d67bbf21f4 --- /dev/null +++ b/designs/container_image_builds_ecs_agentcore.md @@ -0,0 +1,234 @@ +Container Image Builds for ECS and AgentCore +============================================= + +This is the design for extending `sam build`, `sam package`, `sam deploy`, and `sam sync` +to support building and deploying container images for non-Lambda resources: +`AWS::ECS::TaskDefinition` and `AWS::BedrockAgentCore::Runtime`. + +What is the problem? +-------------------- + +SAM CLI provides an excellent developer experience for Lambda Image functions: a single +`sam build && sam deploy` builds the Docker image, pushes to ECR, and deploys via +CloudFormation. However, users deploying containerized workloads to ECS (Fargate) or +Bedrock AgentCore must manage their Docker build/push/deploy pipeline separately, even +when these resources live in the same CloudFormation template alongside Lambda functions. + +This creates a fragmented workflow where developers need external tooling (shell scripts, +Makefiles, or CI/CD steps) for the identical operation: build image → push to ECR → +update template with ECR URI → deploy. + +What will be changed? +--------------------- + +We extend the existing Lambda Image build pipeline to recognize `AWS::ECS::TaskDefinition` +and `AWS::BedrockAgentCore::Runtime` resources that have a `Metadata` block with +`Dockerfile` and `DockerContext`. No new commands are introduced — the existing +`sam build`, `sam package`, `sam deploy`, and `sam sync` gain awareness of these +resource types. + +### Design Principles + +1. **Same convention** — Uses the identical Metadata block as Lambda Image functions + (Dockerfile, DockerContext, DockerTag, DockerBuildArgs, DockerBuildTarget) +2. **No Transform changes** — Works with native CloudFormation resource types +3. **Opt-in** — Only resources with the Metadata block are affected; existing templates + work unchanged +4. **Reuse** — Delegates to the same `_build_lambda_image()` Docker build logic + +Success criteria for the change +------------------------------- + +1. `sam build` discovers and builds container images for ECS TaskDefinitions and + AgentCore Runtimes that have Dockerfile metadata +2. `sam deploy --resolve-image-repos` auto-creates ECR repos for these resources +3. `sam package` / `sam deploy` pushes images to ECR and rewrites the template with + the ECR URI at the correct property path +4. `sam sync` builds, pushes, and triggers redeployment for these resources +5. Multi-container ECS TaskDefinitions can target a specific container via `ContainerName` +6. Architecture can be specified via `Architecture` metadata (e.g., `arm64`) +7. Buildkit support works automatically (shared with Lambda Image builds) +8. No regressions for existing Lambda, Layer, or API builds + +User Experience +--------------- + +### Template Format + +```yaml +Resources: + # AgentCore Runtime + MyAgent: + Type: AWS::BedrockAgentCore::Runtime + Metadata: + Dockerfile: Dockerfile + DockerContext: ./agent + DockerTag: latest + Architecture: arm64 + Properties: + AgentRuntimeName: my_agent + AgentRuntimeArtifact: + ContainerConfiguration: + ContainerUri: placeholder + NetworkConfiguration: + NetworkMode: PUBLIC + RoleArn: !GetAtt AgentRole.Arn + + # ECS TaskDefinition (multi-container) + MyTask: + Type: AWS::ECS::TaskDefinition + Metadata: + Dockerfile: Dockerfile + DockerContext: ./app + DockerTag: latest + ContainerName: web + Properties: + Family: my-app + ContainerDefinitions: + - Name: envoy + Image: public.ecr.aws/envoy:latest + - Name: web + Image: placeholder +``` + +### CLI Usage + +```bash +# Build container images +sam build + +# Deploy with auto ECR repo creation +sam deploy --resolve-image-repos + +# Or with explicit repo +sam deploy --image-repositories SimpleAgent=123456789012.dkr.ecr.us-east-1.amazonaws.com/repo + +# Live sync +sam sync --stack-name my-stack --watch --resolve-image-repos +``` + +### Metadata Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `Dockerfile` | Yes | Path to Dockerfile relative to DockerContext | +| `DockerContext` | Yes | Build context directory relative to template | +| `DockerTag` | No | Image tag (default: `latest`) | +| `DockerBuildArgs` | No | Dict of build args | +| `DockerBuildTarget` | No | Multi-stage build target | +| `DockerBuildExtraParams` | No | List of extra docker build params | +| `Architecture` | No | Target platform: `x86_64` (default) or `arm64` | +| `ContainerName` | No | ECS only: target container in multi-container TaskDefinition | + +Implementation +-------------- + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ sam build │ +├─────────────────────────────────────────────────────────────────┤ +│ BuildContext.run() │ +│ ├── builder.build() → Lambda functions + layers │ +│ └── _build_container_images() → ECS + AgentCore containers │ +│ ├── SamContainerServiceProvider (discovery) │ +│ ├── ContainerBuildDefinition (build graph) │ +│ └── ApplicationBuilder.build_container_images() │ +│ └── _build_lambda_image() (shared Docker logic) │ +├─────────────────────────────────────────────────────────────────┤ +│ sam package / sam deploy │ +│ ├── sync_ecr_stack() → auto-creates ECR repos (companion) │ +│ ├── ECSTaskDefinitionImageResource.export() → push + rewrite │ +│ └── AgentCoreRuntimeImageResource.export() → push + rewrite │ +├─────────────────────────────────────────────────────────────────┤ +│ sam sync │ +│ └── ECSContainerSyncFlow │ +│ ├── gather_resources() → build image │ +│ ├── sync() → push to ECR + force ECS deployment │ +│ └── SyncFlowFactory (registered for both types) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +**`samcli/lib/providers/sam_container_provider.py`** (new) +- `SamContainerServiceProvider`: Scans stacks for ECS/AgentCore resources with + Dockerfile+DockerContext metadata. Returns `ContainerService` NamedTuples. + +**`samcli/lib/build/build_graph.py`** (modified) +- `ContainerBuildDefinition`: Parallel to `FunctionBuildDefinition`. Holds resource + identifier, type, metadata, and architecture. Reads `Architecture` from metadata. + +**`samcli/lib/build/app_builder.py`** (modified) +- `build_container_images()`: Iterates container definitions and builds each. +- `_build_container_image()`: Delegates to `_build_lambda_image()` — same Docker logic. +- `_update_built_resource()`: Extended for ECS (`ContainerDefinitions[N].Image`) and + AgentCore (`AgentRuntimeArtifact.ContainerConfiguration.ContainerUri`). Accepts + optional `metadata` param for `ContainerName` targeting. + +**`samcli/lib/package/packageable_resources.py`** (modified) +- `ECSTaskDefinitionImageResource`: Custom export for nested `ContainerDefinitions[0].Image`. +- `AgentCoreRuntimeImageResource`: Export using jmespath for deeply nested property path. +- Both use `ARTIFACT_TYPE = ZIP` to pass the `PackageType` filter (these resources + don't have a `PackageType` property). + +**`samcli/lib/sync/flows/ecs_container_sync_flow.py`** (new) +- `ECSContainerSyncFlow`: Builds image, pushes to ECR, forces ECS service redeployment + by finding services using the task definition family. + +**`samcli/lib/bootstrap/companion_stack/companion_stack_manager.py`** (modified) +- `sync_ecr_stack()`: Extended to include container service resources when creating + ECR repos via the companion stack. + +### Property Path Mapping + +| Resource Type | Property Path for Image URI | +|---------------|----------------------------| +| `AWS::Serverless::Function` | `ImageUri` | +| `AWS::Lambda::Function` | `Code.ImageUri` | +| `AWS::ECS::TaskDefinition` | `ContainerDefinitions[N].Image` | +| `AWS::BedrockAgentCore::Runtime` | `AgentRuntimeArtifact.ContainerConfiguration.ContainerUri` | + +Alternatives Considered +----------------------- + +### 1. New SAM Transform resource type (e.g., `AWS::Serverless::ContainerService`) + +**Rejected because:** +- Requires changes to the SAM Transform (separate repo, separate approval process) +- Adds coupling between SAM CLI and the Transform +- Users would need to wait for Transform support in all regions +- Native CFN types already work and are well-understood + +### 2. Separate `sam container build` command + +**Rejected because:** +- Fragments the workflow — users would need to remember different commands +- Doesn't integrate with `sam deploy` and `sam sync` naturally +- The existing `sam build` already handles image builds for Lambda + +### 3. Using `PackageType: Image` on ECS/AgentCore resources + +**Rejected because:** +- `PackageType` is a Lambda-specific concept not present on ECS or AgentCore resources +- Would require CloudFormation schema changes +- The Metadata-based approach is already the established pattern + +Breaking Changes +---------------- + +None. This is purely additive: +- Templates without ECS/AgentCore Metadata are unaffected +- The `_update_built_resource` signature change is backward compatible (optional param) +- No existing CLI flags or behaviors change + +Future Extensions +----------------- + +1. **Multiple Dockerfiles per ECS TaskDefinition** — Build multiple containers from + one resource using a list of Metadata entries +2. **`sam local start-ecs`** — Local testing of ECS containers (similar to `sam local start-api`) +3. **Health check integration** — Wait for container health before marking sync complete +4. **Build caching** — Layer-aware caching for container builds (currently rebuilds fully) +5. **`sam init` templates** — Starter templates for ECS+SAM and AgentCore+SAM projects diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 1c6355b030c..d1fbb743063 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -29,7 +29,7 @@ BuildError, UnsupportedBuilderLibraryVersionError, ) -from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR +from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR, ContainerBuildDefinition from samcli.lib.build.bundler import EsbuildBundlerManager from samcli.lib.build.exceptions import ( BuildInsideContainerError, @@ -45,6 +45,7 @@ from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable from samcli.lib.providers.provider import LayerVersion, ResourcesToBuildCollector, Stack from samcli.lib.providers.sam_api_provider import SamApiProvider +from samcli.lib.providers.sam_container_provider import SamContainerServiceProvider from samcli.lib.providers.sam_function_provider import SamFunctionProvider from samcli.lib.providers.sam_layer_provider import SamLayerProvider from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider @@ -302,6 +303,11 @@ def run(self) -> None: self._build_result = builder.build() + # Build container images for ECS/AgentCore resources + container_artifacts = self._build_container_images(builder) + if container_artifacts: + self._build_result.artifacts.update(container_artifacts) + self._handle_build_post_processing(builder, self._build_result) click.secho("\nBuild Succeeded", fg="green") @@ -1192,6 +1198,33 @@ def collect_all_build_resources(self) -> ResourcesToBuildCollector: ) return result + def _build_container_images(self, builder: ApplicationBuilder) -> Dict[str, str]: + """ + Discover and build container images for ECS/AgentCore resources. + + Returns + ------- + Dict[str, str] + Map of resource full_path to built image tag + """ + container_provider = SamContainerServiceProvider(self.stacks) + container_services = list(container_provider.get_all()) + if not container_services: + return {} + + LOG.info("Found %d container service resource(s) to build", len(container_services)) + + container_build_defs = [] + for service in container_services: + build_def = ContainerBuildDefinition( + resource_identifier=service.full_path, + resource_type=service.resource_type, + metadata=service.metadata, + ) + container_build_defs.append(build_def) + + return builder.build_container_images(container_build_defs) + @property def is_building_specific_resource(self) -> bool: """ diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index 7a77f19d53c..8c58edfcaf4 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -54,6 +54,8 @@ 2. AWS::Lambda::Function\n 3. AWS::Serverless::LayerVersion\n 4. AWS::Lambda::LayerVersion\n + 5. AWS::ECS::TaskDefinition (container image)\n + 6. AWS::BedrockAgentCore::Runtime (container image)\n \b Supported Runtimes ------------------ diff --git a/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py b/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py index cfe95e675d9..746d8f1beb4 100644 --- a/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py +++ b/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py @@ -312,6 +312,14 @@ def sync_ecr_stack( function_logical_ids = [ function.full_path for function in function_provider.get_all() if function.packagetype == IMAGE ] + + # Also include ECS/AgentCore container resources that need ECR repos + from samcli.lib.providers.sam_container_provider import SamContainerServiceProvider + + container_provider = SamContainerServiceProvider(stacks) + container_logical_ids = [service.full_path for service in container_provider.get_all()] + function_logical_ids.extend(container_logical_ids) + manager.set_functions(function_logical_ids, image_repositories) image_repositories.update(manager.get_repository_mapping()) manager.sync_repos() diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index c30c948c007..158d6569c2a 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -19,7 +19,12 @@ from aws_lambda_builders.exceptions import LambdaBuilderError from samcli.commands._utils.experimental import get_enabled_experimental_flags -from samcli.lib.build.build_graph import BuildGraph, FunctionBuildDefinition, LayerBuildDefinition +from samcli.lib.build.build_graph import ( + BuildGraph, + ContainerBuildDefinition, + FunctionBuildDefinition, + LayerBuildDefinition, +) from samcli.lib.build.build_strategy import ( BuildStrategy, CachedOrIncrementalBuildStrategyWrapper, @@ -51,7 +56,9 @@ from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.lib.utils.path_utils import check_path_valid_type, convert_path_to_unix_path from samcli.lib.utils.resources import ( + AWS_BEDROCK_AGENTCORE_RUNTIME, AWS_CLOUDFORMATION_STACK, + AWS_ECS_TASK_DEFINITION, AWS_LAMBDA_FUNCTION, AWS_LAMBDA_LAYERVERSION, AWS_SERVERLESS_APPLICATION, @@ -378,7 +385,11 @@ def update_template( if has_build_artifact: ApplicationBuilder._update_built_resource( - built_artifacts[full_path], properties, resource_type or "", store_path + built_artifacts[full_path], + properties, + resource_type or "", + store_path, + resource.get("Metadata"), ) if is_stack: @@ -391,7 +402,9 @@ def update_template( return template_dict @staticmethod - def _update_built_resource(path: str, resource_properties: Dict, resource_type: str, absolute_path: str) -> None: + def _update_built_resource( + path: str, resource_properties: Dict, resource_type: str, absolute_path: str, metadata: Optional[Dict] = None + ) -> None: if resource_type == AWS_SERVERLESS_FUNCTION and resource_properties.get("PackageType", ZIP) == ZIP: resource_properties["CodeUri"] = absolute_path if resource_type == AWS_LAMBDA_FUNCTION and resource_properties.get("PackageType", ZIP) == ZIP: @@ -404,6 +417,26 @@ def _update_built_resource(path: str, resource_properties: Dict, resource_type: resource_properties["Code"] = {"ImageUri": path} if resource_type == AWS_SERVERLESS_FUNCTION and resource_properties.get("PackageType", ZIP) == IMAGE: resource_properties["ImageUri"] = path + if resource_type == AWS_ECS_TASK_DEFINITION: + container_defs = resource_properties.get("ContainerDefinitions", []) + if container_defs: + target_name = metadata.get("ContainerName") if metadata else None + if target_name: + for container_def in container_defs: + if container_def.get("Name") == target_name: + container_def["Image"] = path + break + else: + raise DockerBuildFailed( + f"Metadata.ContainerName '{target_name}' does not match any " + f"container in ContainerDefinitions" + ) + else: + container_defs[0]["Image"] = path + if resource_type == AWS_BEDROCK_AGENTCORE_RUNTIME: + artifact = resource_properties.setdefault("AgentRuntimeArtifact", {}) + container_config = artifact.setdefault("ContainerConfiguration", {}) + container_config["ContainerUri"] = path def _build_lambda_image(self, function_name: str, metadata: Dict, architecture: str) -> str: """ @@ -538,6 +571,53 @@ def _load_lambda_image(self, image_archive_path: str) -> str: except (docker.errors.APIError, OSError, ContainerArchiveImageLoadFailedException) as ex: raise DockerBuildFailed(msg=str(ex)) from ex + def build_container_images(self, container_build_definitions: List[ContainerBuildDefinition]) -> Dict[str, str]: + """ + Build container images for non-Lambda resources (ECS, AgentCore). + + Parameters + ---------- + container_build_definitions : List[ContainerBuildDefinition] + List of container build definitions to build + + Returns + ------- + Dict[str, str] + Map of resource identifier to the built image tag + """ + artifacts: Dict[str, str] = {} + for container_def in container_build_definitions: + if not container_def.metadata: + continue + image_tag = self._build_container_image( + container_def.resource_identifier, + container_def.metadata, + container_def.architecture, + ) + artifacts[container_def.resource_identifier] = image_tag + return artifacts + + def _build_container_image(self, resource_name: str, metadata: Dict, architecture: str) -> str: + """ + Build a container image for an ECS/AgentCore resource. Uses the same Metadata + convention as Lambda Image builds (Dockerfile, DockerContext, DockerBuildArgs, etc.) + + Parameters + ---------- + resource_name : str + Logical ID or identifier of the resource + metadata : dict + Metadata block from the resource + architecture : str + Target architecture + + Returns + ------- + str + The full tag of the built image + """ + return self._build_lambda_image(resource_name, metadata, architecture) + def _build_layer( self, layer_name: str, diff --git a/samcli/lib/build/build_graph.py b/samcli/lib/build/build_graph.py index c31ee039438..e2199e79902 100644 --- a/samcli/lib/build/build_graph.py +++ b/samcli/lib/build/build_graph.py @@ -200,12 +200,14 @@ class BuildGraph: # global table build definitions key FUNCTION_BUILD_DEFINITIONS = "function_build_definitions" LAYER_BUILD_DEFINITIONS = "layer_build_definitions" + CONTAINER_BUILD_DEFINITIONS = "container_build_definitions" def __init__(self, build_dir: str) -> None: # put build.toml file inside .aws-sam folder self._filepath = Path(build_dir).parent.joinpath(DEFAULT_BUILD_GRAPH_FILE_NAME) self._function_build_definitions: List["FunctionBuildDefinition"] = [] self._layer_build_definitions: List["LayerBuildDefinition"] = [] + self._container_build_definitions: List["ContainerBuildDefinition"] = [] self._atomic_read() def get_function_build_definitions(self) -> Tuple["FunctionBuildDefinition", ...]: @@ -214,6 +216,21 @@ def get_function_build_definitions(self) -> Tuple["FunctionBuildDefinition", ... def get_layer_build_definitions(self) -> Tuple["LayerBuildDefinition", ...]: return tuple(self._layer_build_definitions) + def get_container_build_definitions(self) -> Tuple["ContainerBuildDefinition", ...]: + return tuple(self._container_build_definitions) + + def put_container_build_definition(self, container_build_definition: "ContainerBuildDefinition") -> None: + """ + Puts the newly read container build definition into existing build graph. + Each container build definition is unique per resource identifier. + """ + if container_build_definition not in self._container_build_definitions: + LOG.debug( + "Adding container build definition: %s", + container_build_definition, + ) + self._container_build_definitions.append(container_build_definition) + def get_function_build_definition_with_full_path( self, function_full_path: str ) -> Optional["FunctionBuildDefinition"]: @@ -701,3 +718,67 @@ def __eq__(self, other: Any) -> bool: and self.env_vars == other.env_vars and self.architecture == other.architecture ) + + +class ContainerBuildDefinition(AbstractBuildDefinition): + """ + ContainerBuildDefinition holds information about each unique container image build + for non-Lambda resources (ECS TaskDefinitions, AgentCore, etc.) + """ + + def __init__( + self, + resource_identifier: str, + resource_type: str, + metadata: Optional[Dict], + source_hash: str = "", + manifest_hash: str = "", + env_vars: Optional[Dict] = None, + architecture: str = X86_64, + ) -> None: + # Allow metadata to override architecture (e.g., "arm64" for AgentCore) + if metadata and metadata.get("Architecture"): + architecture = metadata["Architecture"] + super().__init__(source_hash, manifest_hash, env_vars, architecture) + self.resource_identifier = resource_identifier + self.resource_type = resource_type + + metadata_copied = deepcopy(metadata) if metadata else {} + metadata_copied.pop(SAM_RESOURCE_ID_KEY, "") + metadata_copied.pop(SAM_IS_NORMALIZED, "") + self.metadata = metadata_copied + + @property + def dockerfile(self) -> Optional[str]: + return self.metadata.get("Dockerfile") if self.metadata else None + + @property + def docker_context(self) -> Optional[str]: + return self.metadata.get("DockerContext") if self.metadata else None + + @property + def docker_build_args(self) -> Dict: + return self.metadata.get("DockerBuildArgs", {}) if self.metadata else {} + + @property + def docker_build_target(self) -> Optional[str]: + return self.metadata.get("DockerBuildTarget") if self.metadata else None + + def get_resource_full_paths(self) -> str: + return self.resource_identifier + + def __str__(self) -> str: + return ( + f"ContainerBuildDefinition({self.resource_identifier}, {self.resource_type}, " + f"{self.source_hash}, {self.uuid}, {self.metadata}, {self.architecture})" + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, ContainerBuildDefinition): + return False + return ( + self.resource_identifier == other.resource_identifier + and self.resource_type == other.resource_type + and self.metadata == other.metadata + and self.architecture == other.architecture + ) diff --git a/samcli/lib/package/artifact_exporter.py b/samcli/lib/package/artifact_exporter.py index e672c6a75b9..cfa06123a3b 100644 --- a/samcli/lib/package/artifact_exporter.py +++ b/samcli/lib/package/artifact_exporter.py @@ -492,6 +492,7 @@ def export(self) -> Dict: # Export code resources exporter = exporter_class(self.uploaders, self.code_signer, cache) exporter.parent_parameter_values = self.parameter_values + exporter.resource_metadata = resource.get("Metadata") exporter.export(full_path, resource_dict, self.template_dir) return self.template_dict @@ -519,6 +520,7 @@ def delete(self, retain_resources: List): continue # Delete code resources exporter = exporter_class(self.uploaders, None) + exporter.resource_metadata = resource.get("Metadata") exporter.delete(resource_id, resource_dict) def get_ecr_repos(self): diff --git a/samcli/lib/package/packageable_resources.py b/samcli/lib/package/packageable_resources.py index d63136df8fb..87f8c484891 100644 --- a/samcli/lib/package/packageable_resources.py +++ b/samcli/lib/package/packageable_resources.py @@ -33,9 +33,11 @@ AWS_APPSYNC_FUNCTIONCONFIGURATION, AWS_APPSYNC_GRAPHQLSCHEMA, AWS_APPSYNC_RESOLVER, + AWS_BEDROCK_AGENTCORE_RUNTIME, AWS_CLOUDFORMATION_MODULEVERSION, AWS_CLOUDFORMATION_RESOURCEVERSION, AWS_ECR_REPOSITORY, + AWS_ECS_TASK_DEFINITION, AWS_ELASTICBEANSTALK_APPLICATIONVERSION, AWS_GLUE_JOB, AWS_LAMBDA_FUNCTION, @@ -688,6 +690,99 @@ def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: st shutil.rmtree(temp_dir) +class ECSTaskDefinitionImageResource(ResourceImage): + """ + Represents an ECS TaskDefinition with a container image that needs to be pushed to ECR. + The image reference is at ContainerDefinitions[0].Image. + """ + + RESOURCE_TYPE = AWS_ECS_TASK_DEFINITION + PROPERTY_NAME = RESOURCES_WITH_IMAGE_COMPONENT[RESOURCE_TYPE][0] + ARTIFACT_TYPE = ZIP # No PackageType property; defaults to ZIP in the filter + + def _get_target_index(self, container_defs): + """Find the target container index using ContainerName from Metadata.""" + metadata = getattr(self, "resource_metadata", None) or {} + target_name = metadata.get("ContainerName") + if target_name: + for i, cd in enumerate(container_defs): + if cd.get("Name") == target_name: + return i + raise exceptions.ExportFailedError( + resource_id="", + property_name=self.PROPERTY_NAME, + property_value=target_name, + ex=ValueError( + f"Metadata.ContainerName '{target_name}' does not match any " f"container in ContainerDefinitions" + ), + ) + return 0 + + def export(self, resource_id, resource_dict, parent_dir): + if resource_dict is None: + return + + container_defs = resource_dict.get("ContainerDefinitions", []) + if not container_defs: + return + + target_idx = self._get_target_index(container_defs) + property_value = container_defs[target_idx].get("Image") + if not property_value or isinstance(property_value, dict) or is_ecr_url(property_value): + return + + try: + uploaded_url = self.uploader.upload(property_value, resource_id) + container_defs[target_idx]["Image"] = uploaded_url + except Exception as ex: + LOG.debug("Unable to export", exc_info=ex) + raise exceptions.ExportFailedError( + resource_id=resource_id, + property_name=self.PROPERTY_NAME, + property_value=property_value, + ex=ex, + ) + + def delete(self, resource_id, resource_dict): + if resource_dict is None: + return + container_defs = resource_dict.get("ContainerDefinitions", []) + if not container_defs: + return + target_idx = self._get_target_index(container_defs) + remote_path = container_defs[target_idx].get("Image") + if isinstance(remote_path, str) and is_ecr_url(remote_path): + self.uploader.delete_artifact( + image_uri=remote_path, resource_id=resource_id, property_name=self.PROPERTY_NAME + ) + + +class AgentCoreRuntimeImageResource(ResourceImage): + """ + Represents a Bedrock AgentCore Runtime resource with a container image. + Property path: AgentRuntimeArtifact.ContainerConfiguration.ContainerUri + """ + + RESOURCE_TYPE = AWS_BEDROCK_AGENTCORE_RUNTIME + PROPERTY_NAME = RESOURCES_WITH_IMAGE_COMPONENT[RESOURCE_TYPE][0] + ARTIFACT_TYPE = ZIP # No PackageType property; defaults to ZIP in the filter + + def do_export(self, resource_id, resource_dict, parent_dir): + uploaded_url = upload_local_image_artifacts( + resource_id, resource_dict, self.PROPERTY_NAME, parent_dir, self.uploader + ) + set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, uploaded_url) + + def delete(self, resource_id, resource_dict): + if resource_dict is None: + return + remote_path = jmespath.search(self.PROPERTY_NAME, resource_dict) + if isinstance(remote_path, str) and is_ecr_url(remote_path): + self.uploader.delete_artifact( + image_uri=remote_path, resource_id=resource_id, property_name=self.PROPERTY_NAME + ) + + RESOURCES_EXPORT_LIST = [ ServerlessFunctionResource, ServerlessFunctionImageResource, @@ -713,6 +808,8 @@ def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: st CloudFormationModuleVersionModulePackage, CloudFormationResourceVersionSchemaHandlerPackage, ECRResource, + ECSTaskDefinitionImageResource, + AgentCoreRuntimeImageResource, GraphQLApiSchemaResource, GraphQLApiCodeResource, ] diff --git a/samcli/lib/providers/sam_container_provider.py b/samcli/lib/providers/sam_container_provider.py new file mode 100644 index 00000000000..db9689a4104 --- /dev/null +++ b/samcli/lib/providers/sam_container_provider.py @@ -0,0 +1,100 @@ +""" +Class that provides container service resources (ECS, AgentCore) from a given SAM/CFN template +""" + +import logging +from typing import Dict, Iterator, List, NamedTuple, Optional + +from samcli.lib.providers.provider import Stack, get_full_path +from samcli.lib.utils.resources import ( + AWS_BEDROCK_AGENTCORE_RUNTIME, + AWS_ECS_TASK_DEFINITION, +) + +LOG = logging.getLogger(__name__) + +# Resource types that support container image builds +CONTAINER_IMAGE_RESOURCE_TYPES = [ + AWS_ECS_TASK_DEFINITION, + AWS_BEDROCK_AGENTCORE_RUNTIME, +] + + +class ContainerService(NamedTuple): + """ + Represents a container service resource that needs an image built. + """ + + # Logical ID of the resource + resource_id: str + # Full path including stack path + full_path: str + # Resource type (e.g., AWS::ECS::TaskDefinition) + resource_type: str + # Metadata dict containing Dockerfile, DockerContext, etc. + metadata: Dict + # Resource properties + properties: Dict + # Stack path + stack_path: str = "" + + +class SamContainerServiceProvider: + """ + Extracts container service resources from SAM/CFN templates that have + Metadata with Dockerfile and DockerContext, indicating they need image builds. + """ + + def __init__(self, stacks: List[Stack]) -> None: + self._stacks = stacks + self._container_services: Dict[str, ContainerService] = {} + self._extract_container_services() + + def _extract_container_services(self) -> None: + for stack in self._stacks: + resources = getattr(stack, "resources", None) + if not resources or not isinstance(resources, dict): + continue + for logical_id, resource in resources.items(): + resource_type = resource.get("Type", "") + if resource_type not in CONTAINER_IMAGE_RESOURCE_TYPES: + continue + + metadata = resource.get("Metadata", {}) + if not self._has_container_build_metadata(metadata): + continue + + full_path = get_full_path(stack.stack_path, logical_id) + properties = resource.get("Properties", {}) + + container_service = ContainerService( + resource_id=logical_id, + full_path=full_path, + resource_type=resource_type, + metadata=metadata, + properties=properties, + stack_path=stack.stack_path, + ) + self._container_services[full_path] = container_service + LOG.debug("Found container service resource: %s (%s)", full_path, resource_type) + + @staticmethod + def _has_container_build_metadata(metadata: Optional[Dict]) -> bool: + """Check if metadata contains the required fields for a container image build.""" + if not metadata: + return False + return bool(metadata.get("Dockerfile") and metadata.get("DockerContext")) + + def get(self, name: str) -> Optional[ContainerService]: + """Get a container service by full path or logical ID.""" + if name in self._container_services: + return self._container_services[name] + # Try matching by logical ID alone + for full_path, service in self._container_services.items(): + if service.resource_id == name: + return service + return None + + def get_all(self) -> Iterator[ContainerService]: + """Return all container services.""" + yield from self._container_services.values() diff --git a/samcli/lib/sync/flows/ecs_container_sync_flow.py b/samcli/lib/sync/flows/ecs_container_sync_flow.py new file mode 100644 index 00000000000..4d4149525be --- /dev/null +++ b/samcli/lib/sync/flows/ecs_container_sync_flow.py @@ -0,0 +1,222 @@ +"""SyncFlow for ECS TaskDefinition container image resources""" + +import logging +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from botocore.exceptions import ClientError +from docker.client import DockerClient + +from samcli.lib.build.app_builder import ApplicationBuilder, ApplicationBuildResult +from samcli.lib.build.build_graph import ContainerBuildDefinition +from samcli.lib.package.ecr_uploader import ECRUploader +from samcli.lib.providers.provider import Stack +from samcli.lib.providers.sam_container_provider import SamContainerServiceProvider +from samcli.lib.sync.sync_flow import ApiCallTypes, ResourceAPICall, SyncFlow +from samcli.local.docker.utils import get_validated_container_client + +if TYPE_CHECKING: # pragma: no cover + from samcli.commands.build.build_context import BuildContext + from samcli.commands.deploy.deploy_context import DeployContext + from samcli.commands.sync.sync_context import SyncContext + +LOG = logging.getLogger(__name__) + + +class ECSContainerSyncFlow(SyncFlow): + """SyncFlow for ECS TaskDefinition and AgentCore container image resources. + + Builds the container image, pushes to ECR, and triggers an ECS service update. + """ + + _resource_identifier: str + _ecr_client: Optional[Any] + _docker_client: Optional[DockerClient] + _ecs_client: Optional[Any] + _image_name: Optional[str] + + def __init__( + self, + resource_identifier: str, + build_context: "BuildContext", + deploy_context: "DeployContext", + sync_context: "SyncContext", + physical_id_mapping: Dict[str, str], + stacks: List[Stack], + application_build_result: Optional[ApplicationBuildResult], + ): + super().__init__( + build_context, + deploy_context, + sync_context, + physical_id_mapping, + f"ECSContainer {resource_identifier}", + stacks, + application_build_result, + ) + self._resource_identifier = resource_identifier + self._ecr_client = None + self._docker_client = None + self._ecs_client = None + self._image_name = None + + @property + def sync_state_identifier(self) -> str: + return self._resource_identifier + + def _get_docker_client(self) -> DockerClient: + if not self._docker_client: + self._docker_client = get_validated_container_client() + return self._docker_client + + def _get_ecr_client(self) -> Any: + if not self._ecr_client: + self._ecr_client = self._boto_client("ecr") + return self._ecr_client + + def _get_ecs_client(self) -> Any: + if not self._ecs_client: + self._ecs_client = self._boto_client("ecs") + return self._ecs_client + + def gather_resources(self) -> None: + """Build the container image.""" + if self._application_build_result: + self._image_name = self._application_build_result.artifacts.get(self._resource_identifier) + else: + self._build_from_scratch() + + if self._image_name: + docker_img = self._get_docker_client().images.get(self._image_name) + if docker_img and docker_img.attrs.get("Id"): + self._local_sha = str(docker_img.attrs.get("Id")) + + def _build_from_scratch(self) -> None: + """Build the container image from scratch using the provider.""" + container_provider = SamContainerServiceProvider(self._stacks or []) + service = container_provider.get(self._resource_identifier) + if not service: + LOG.warning("Cannot find container service resource '%s'", self._resource_identifier) + return + + build_def = ContainerBuildDefinition( + resource_identifier=service.full_path, + resource_type=service.resource_type, + metadata=service.metadata, + ) + + builder = ApplicationBuilder( + ( + self._build_context.collect_build_resources(self._resource_identifier) + if hasattr(self._build_context, "collect_build_resources") + else self._build_context.get_resources_to_build() + ), + self._build_context.build_dir, + self._build_context.base_dir, + self._build_context.cache_dir, + cached=False, + is_building_specific_resource=True, + manifest_path_override=self._build_context.manifest_path_override, + container_manager=self._build_context.container_manager, + mode=self._build_context.mode, + build_in_source=self._build_context.build_in_source, + ) + artifacts = builder.build_container_images([build_def]) + self._image_name = artifacts.get(self._resource_identifier) + + def compare_remote(self) -> bool: + return False + + def sync(self) -> None: + if not self._image_name: + LOG.debug("%sSkipping sync. Image name is None.", self.log_prefix) + return + + # Get ECR repo from deploy context + ecr_repo = self._deploy_context.image_repository + if ( + not ecr_repo + and self._deploy_context.image_repositories + and isinstance(self._deploy_context.image_repositories, dict) + ): + ecr_repo = self._deploy_context.image_repositories.get(self._resource_identifier) + + if not ecr_repo: + LOG.warning( + "%sNo ECR repository configured for %s. " "Use --image-repository or --image-repositories.", + self.log_prefix, + self._resource_identifier, + ) + return + + # Push image to ECR + ecr_uploader = ECRUploader(self._get_docker_client(), self._get_ecr_client(), ecr_repo, None) + ecr_uploader.upload(self._image_name, self._resource_identifier) + + # Force new deployment of ECS service if one is associated + self._force_ecs_deployment() + + def _force_ecs_deployment(self) -> None: + """Force new deployment for ECS services in the stack that use this task definition.""" + physical_id = self._physical_id_mapping.get(self._resource_identifier) + if not physical_id: + LOG.debug("%sNo physical ID found for %s, skipping ECS update", self.log_prefix, self._resource_identifier) + return + + try: + ecs_client = self._get_ecs_client() + + # Find ECS services in the same stack by looking at physical_id_mapping + # ECS Service physical IDs are ARNs like arn:aws:ecs:region:account:service/cluster/name + for resource_id, resource_physical_id in self._physical_id_mapping.items(): + if not resource_physical_id or "service/" not in str(resource_physical_id): + continue + # Check if this service uses our task definition + try: + # Extract cluster and service from the ARN + parts = resource_physical_id.rsplit("/", 2) + if len(parts) < 3: + continue + cluster = parts[-2] + service_name = parts[-1] + + svc_response = ecs_client.describe_services(cluster=cluster, services=[service_name]) + for svc in svc_response.get("services", []): + svc_task_def = svc.get("taskDefinition", "") + # Check if this service references our task definition family + if physical_id in svc_task_def or ( + svc_task_def.rsplit("/", 1)[-1].split(":", 1)[0] + == physical_id.rsplit("/", 1)[-1].split(":", 1)[0] + ): + ecs_client.update_service( + cluster=cluster, + service=service_name, + forceNewDeployment=True, + ) + LOG.info( + "%sForced new deployment for service %s", + self.log_prefix, + service_name, + ) + except ClientError: + LOG.warning( + "%sFailed to update service %s", + self.log_prefix, + resource_id, + exc_info=True, + ) + except ClientError: + LOG.warning("%sFailed to force ECS deployment", self.log_prefix, exc_info=True) + + def gather_dependencies(self) -> List[SyncFlow]: + return [] + + def _get_resource_api_calls(self) -> List[ResourceAPICall]: + return [ + ResourceAPICall( + self._resource_identifier, + [ApiCallTypes.UPDATE_FUNCTION_CODE], + ) + ] + + def _equality_keys(self) -> Any: + return self._resource_identifier diff --git a/samcli/lib/sync/sync_flow_factory.py b/samcli/lib/sync/sync_flow_factory.py index 4fbdda54b3b..0fb33e22cd8 100644 --- a/samcli/lib/sync/sync_flow_factory.py +++ b/samcli/lib/sync/sync_flow_factory.py @@ -12,6 +12,7 @@ from samcli.lib.package.utils import is_local_folder, is_zip_file from samcli.lib.providers.provider import Function, FunctionBuildInfo, ResourceIdentifier, Stack from samcli.lib.sync.flows.auto_dependency_layer_sync_flow import AutoDependencyLayerParentSyncFlow +from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow from samcli.lib.sync.flows.function_sync_flow import FunctionSyncFlow from samcli.lib.sync.flows.http_api_sync_flow import HttpApiSyncFlow from samcli.lib.sync.flows.image_function_sync_flow import ImageFunctionSyncFlow @@ -39,6 +40,8 @@ from samcli.lib.utils.resources import ( AWS_APIGATEWAY_RESTAPI, AWS_APIGATEWAY_V2_API, + AWS_BEDROCK_AGENTCORE_RUNTIME, + AWS_ECS_TASK_DEFINITION, AWS_LAMBDA_FUNCTION, AWS_LAMBDA_LAYERVERSION, AWS_SERVERLESS_API, @@ -338,6 +341,21 @@ def _create_stepfunctions_flow( self._stacks, ) + def _create_ecs_container_flow( + self, + resource_identifier: ResourceIdentifier, + application_build_result: Optional[ApplicationBuildResult], + ) -> Optional[SyncFlow]: + return ECSContainerSyncFlow( + str(resource_identifier), + self._build_context, + self._deploy_context, + self._sync_context, + self._physical_id_mapping, + self._stacks, + application_build_result, + ) + GeneratorFunction = Callable[ ["SyncFlowFactory", ResourceIdentifier, Optional[ApplicationBuildResult]], Optional[SyncFlow] ] @@ -352,6 +370,8 @@ def _create_stepfunctions_flow( AWS_APIGATEWAY_V2_API: _create_api_flow, AWS_SERVERLESS_STATEMACHINE: _create_stepfunctions_flow, AWS_STEPFUNCTIONS_STATEMACHINE: _create_stepfunctions_flow, + AWS_ECS_TASK_DEFINITION: _create_ecs_container_flow, + AWS_BEDROCK_AGENTCORE_RUNTIME: _create_ecs_container_flow, } # SyncFlow mapping between resource type and creation function diff --git a/samcli/lib/utils/resources.py b/samcli/lib/utils/resources.py index cb99abc8d61..0d8a75e681b 100644 --- a/samcli/lib/utils/resources.py +++ b/samcli/lib/utils/resources.py @@ -58,6 +58,14 @@ AWS_SERVERLESS_STATEMACHINE = "AWS::Serverless::StateMachine" AWS_STEPFUNCTIONS_STATEMACHINE = "AWS::StepFunctions::StateMachine" AWS_ECR_REPOSITORY = "AWS::ECR::Repository" + +# ECS +AWS_ECS_TASK_DEFINITION = "AWS::ECS::TaskDefinition" +AWS_ECS_SERVICE = "AWS::ECS::Service" + +# AgentCore +AWS_BEDROCK_AGENTCORE_RUNTIME = "AWS::BedrockAgentCore::Runtime" + AWS_APPLICATION_INSIGHTS = "AWS::ApplicationInsights::Application" AWS_RESOURCE_GROUP = "AWS::ResourceGroups::Group" @@ -99,6 +107,8 @@ AWS_SERVERLESS_FUNCTION: ["ImageUri"], AWS_LAMBDA_FUNCTION: ["Code.ImageUri"], AWS_ECR_REPOSITORY: ["RepositoryName"], + AWS_ECS_TASK_DEFINITION: ["ContainerDefinitions.Image"], + AWS_BEDROCK_AGENTCORE_RUNTIME: ["AgentRuntimeArtifact.ContainerConfiguration.ContainerUri"], } NESTED_STACKS_RESOURCES = { diff --git a/tests/integration/buildcmd/test_build_cmd_container_image.py b/tests/integration/buildcmd/test_build_cmd_container_image.py new file mode 100644 index 00000000000..c34bd7cab90 --- /dev/null +++ b/tests/integration/buildcmd/test_build_cmd_container_image.py @@ -0,0 +1,63 @@ +"""Integration test for ECS/AgentCore container image builds""" + +import os +import shutil +import tempfile +from pathlib import Path +from unittest import TestCase + +import yaml + +from samcli.commands.build.build_context import BuildContext + + +class TestContainerImageBuild(TestCase): + """Test that samdev build correctly handles ECS and AgentCore container resources.""" + + template_path = str( + Path(__file__).resolve().parents[1] / "testdata" / "buildcmd" / "container_image" / "template.yaml" + ) + + def setUp(self): + self.build_dir = tempfile.mkdtemp() + self.cache_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.build_dir, ignore_errors=True) + shutil.rmtree(self.cache_dir, ignore_errors=True) + + def test_build_discovers_and_builds_container_resources(self): + """Verify that sam build discovers AgentCore and ECS resources and produces built artifacts.""" + with BuildContext( + resource_identifier=None, + template_file=self.template_path, + base_dir=None, + build_dir=self.build_dir, + cache_dir=self.cache_dir, + cached=False, + parallel=False, + mode=None, + ) as ctx: + ctx.run() + + # Verify the output template was created + output_template = Path(self.build_dir) / "template.yaml" + self.assertTrue(output_template.exists(), "Built template should exist") + + with open(output_template) as f: + built_template = yaml.safe_load(f) + + resources = built_template["Resources"] + + # AgentCore: ContainerUri should be replaced with the built image tag + agent_uri = resources["SimpleAgent"]["Properties"]["AgentRuntimeArtifact"]["ContainerConfiguration"][ + "ContainerUri" + ] + self.assertNotEqual(agent_uri, "placeholder") + self.assertIn("simpleagent", agent_uri.lower()) + + # ECS: The 'web' container (second one) should be updated, sidecar unchanged + container_defs = resources["ECSTask"]["Properties"]["ContainerDefinitions"] + self.assertEqual(container_defs[0]["Image"], "public.ecr.aws/envoy:latest") # sidecar untouched + self.assertNotEqual(container_defs[1]["Image"], "placeholder") # web updated + self.assertIn("ecstask", container_defs[1]["Image"].lower()) diff --git a/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile b/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile new file mode 100644 index 00000000000..57f45c64404 --- /dev/null +++ b/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile @@ -0,0 +1,4 @@ +FROM python:3.12-slim +WORKDIR /app +RUN echo "hello" > /app/test.txt +CMD ["python", "-c", "print('test')"] diff --git a/tests/integration/testdata/buildcmd/container_image/template.yaml b/tests/integration/testdata/buildcmd/container_image/template.yaml new file mode 100644 index 00000000000..b261dc611fa --- /dev/null +++ b/tests/integration/testdata/buildcmd/container_image/template.yaml @@ -0,0 +1,33 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Integration test for ECS/AgentCore container builds + +Resources: + SimpleAgent: + Type: AWS::BedrockAgentCore::Runtime + Metadata: + Dockerfile: Dockerfile + DockerContext: ./agent + DockerTag: latest + Properties: + AgentRuntimeName: test_agent + AgentRuntimeArtifact: + ContainerConfiguration: + ContainerUri: placeholder + NetworkConfiguration: + NetworkMode: PUBLIC + RoleArn: arn:aws:iam::123456789012:role/TestRole + + ECSTask: + Type: AWS::ECS::TaskDefinition + Metadata: + Dockerfile: Dockerfile + DockerContext: ./agent + DockerTag: latest + ContainerName: web + Properties: + Family: test-task + ContainerDefinitions: + - Name: sidecar + Image: public.ecr.aws/envoy:latest + - Name: web + Image: placeholder diff --git a/tests/unit/lib/build_module/test_container_build_definition.py b/tests/unit/lib/build_module/test_container_build_definition.py new file mode 100644 index 00000000000..1ab39fc7b95 --- /dev/null +++ b/tests/unit/lib/build_module/test_container_build_definition.py @@ -0,0 +1,103 @@ +"""Tests for ContainerBuildDefinition in build_graph.py""" + +from unittest import TestCase + +from samcli.lib.build.build_graph import ContainerBuildDefinition +from samcli.lib.utils.architecture import ARM64, X86_64 +from samcli.lib.utils.resources import AWS_BEDROCK_AGENTCORE_RUNTIME, AWS_ECS_TASK_DEFINITION + + +class TestContainerBuildDefinition(TestCase): + def test_basic_properties(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app", "DockerBuildArgs": {"ENV": "prod"}} + cbd = ContainerBuildDefinition( + resource_identifier="MyTask", + resource_type=AWS_ECS_TASK_DEFINITION, + metadata=metadata, + ) + self.assertEqual(cbd.resource_identifier, "MyTask") + self.assertEqual(cbd.resource_type, AWS_ECS_TASK_DEFINITION) + self.assertEqual(cbd.dockerfile, "Dockerfile") + self.assertEqual(cbd.docker_context, "./app") + self.assertEqual(cbd.docker_build_args, {"ENV": "prod"}) + self.assertIsNone(cbd.docker_build_target) + self.assertEqual(cbd.architecture, X86_64) + + def test_architecture_from_metadata(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./agent", "Architecture": "arm64"} + cbd = ContainerBuildDefinition( + resource_identifier="MyAgent", + resource_type=AWS_BEDROCK_AGENTCORE_RUNTIME, + metadata=metadata, + ) + self.assertEqual(cbd.architecture, ARM64) + + def test_architecture_default_when_not_in_metadata(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app"} + cbd = ContainerBuildDefinition( + resource_identifier="MyTask", + resource_type=AWS_ECS_TASK_DEFINITION, + metadata=metadata, + ) + self.assertEqual(cbd.architecture, X86_64) + + def test_docker_build_target(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app", "DockerBuildTarget": "release"} + cbd = ContainerBuildDefinition( + resource_identifier="MyTask", + resource_type=AWS_ECS_TASK_DEFINITION, + metadata=metadata, + ) + self.assertEqual(cbd.docker_build_target, "release") + + def test_equality_same(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app"} + cbd1 = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, metadata) + cbd2 = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, metadata) + self.assertEqual(cbd1, cbd2) + + def test_equality_different_resource_id(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app"} + cbd1 = ContainerBuildDefinition("MyTask1", AWS_ECS_TASK_DEFINITION, metadata) + cbd2 = ContainerBuildDefinition("MyTask2", AWS_ECS_TASK_DEFINITION, metadata) + self.assertNotEqual(cbd1, cbd2) + + def test_equality_different_type(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app"} + cbd1 = ContainerBuildDefinition("MyRes", AWS_ECS_TASK_DEFINITION, metadata) + cbd2 = ContainerBuildDefinition("MyRes", AWS_BEDROCK_AGENTCORE_RUNTIME, metadata) + self.assertNotEqual(cbd1, cbd2) + + def test_equality_different_metadata(self): + cbd1 = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, {"Dockerfile": "A", "DockerContext": "."}) + cbd2 = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, {"Dockerfile": "B", "DockerContext": "."}) + self.assertNotEqual(cbd1, cbd2) + + def test_equality_not_container_build_definition(self): + metadata = {"Dockerfile": "Dockerfile", "DockerContext": "./app"} + cbd = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, metadata) + self.assertNotEqual(cbd, "not a build def") + + def test_get_resource_full_paths(self): + cbd = ContainerBuildDefinition( + "Stack/MyTask", AWS_ECS_TASK_DEFINITION, {"Dockerfile": "D", "DockerContext": "."} + ) + self.assertEqual(cbd.get_resource_full_paths(), "Stack/MyTask") + + def test_str_representation(self): + cbd = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, {"Dockerfile": "D", "DockerContext": "."}) + result = str(cbd) + self.assertIn("ContainerBuildDefinition", result) + self.assertIn("MyTask", result) + self.assertIn(AWS_ECS_TASK_DEFINITION, result) + + def test_none_metadata(self): + cbd = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, None) + self.assertIsNone(cbd.dockerfile) + self.assertIsNone(cbd.docker_context) + self.assertEqual(cbd.docker_build_args, {}) + + def test_strips_sam_metadata_keys(self): + metadata = {"Dockerfile": "D", "DockerContext": ".", "SamResourceId": "should_be_removed"} + cbd = ContainerBuildDefinition("MyTask", AWS_ECS_TASK_DEFINITION, metadata) + self.assertNotIn("SamResourceId", cbd.metadata) diff --git a/tests/unit/lib/build_module/test_container_build_integration.py b/tests/unit/lib/build_module/test_container_build_integration.py new file mode 100644 index 00000000000..fcc800c3a89 --- /dev/null +++ b/tests/unit/lib/build_module/test_container_build_integration.py @@ -0,0 +1,259 @@ +"""Tests for ECS/AgentCore container build integration across modules""" + +from unittest import TestCase +from unittest.mock import MagicMock, patch, Mock +from copy import deepcopy + +from samcli.lib.build.app_builder import ApplicationBuilder +from samcli.lib.build.build_graph import ContainerBuildDefinition +from samcli.lib.package.packageable_resources import ( + AgentCoreRuntimeImageResource, + ECSTaskDefinitionImageResource, +) +from samcli.lib.sync.sync_flow_factory import SyncFlowFactory +from samcli.lib.utils.packagetype import IMAGE, ZIP +from samcli.lib.utils.resources import ( + AWS_BEDROCK_AGENTCORE_RUNTIME, + AWS_ECS_TASK_DEFINITION, + RESOURCES_WITH_IMAGE_COMPONENT, +) + + +class TestResourceConstants(TestCase): + def test_ecs_task_definition_in_image_components(self): + self.assertIn(AWS_ECS_TASK_DEFINITION, RESOURCES_WITH_IMAGE_COMPONENT) + self.assertEqual(RESOURCES_WITH_IMAGE_COMPONENT[AWS_ECS_TASK_DEFINITION], ["ContainerDefinitions.Image"]) + + def test_agentcore_in_image_components(self): + self.assertIn(AWS_BEDROCK_AGENTCORE_RUNTIME, RESOURCES_WITH_IMAGE_COMPONENT) + self.assertEqual( + RESOURCES_WITH_IMAGE_COMPONENT[AWS_BEDROCK_AGENTCORE_RUNTIME], + ["AgentRuntimeArtifact.ContainerConfiguration.ContainerUri"], + ) + + def test_resource_type_values(self): + self.assertEqual(AWS_ECS_TASK_DEFINITION, "AWS::ECS::TaskDefinition") + self.assertEqual(AWS_BEDROCK_AGENTCORE_RUNTIME, "AWS::BedrockAgentCore::Runtime") + + +class TestECSTaskDefinitionImageResource(TestCase): + def test_resource_type(self): + self.assertEqual(ECSTaskDefinitionImageResource.RESOURCE_TYPE, AWS_ECS_TASK_DEFINITION) + + def test_property_name(self): + self.assertEqual(ECSTaskDefinitionImageResource.PROPERTY_NAME, "ContainerDefinitions.Image") + + def test_artifact_type_is_zip(self): + # ZIP so it passes the PackageType filter (ECS has no PackageType property) + self.assertEqual(ECSTaskDefinitionImageResource.ARTIFACT_TYPE, ZIP) + + +class TestAgentCoreRuntimeImageResource(TestCase): + def test_resource_type(self): + self.assertEqual(AgentCoreRuntimeImageResource.RESOURCE_TYPE, AWS_BEDROCK_AGENTCORE_RUNTIME) + + def test_property_name(self): + self.assertEqual( + AgentCoreRuntimeImageResource.PROPERTY_NAME, + "AgentRuntimeArtifact.ContainerConfiguration.ContainerUri", + ) + + def test_artifact_type_is_zip(self): + self.assertEqual(AgentCoreRuntimeImageResource.ARTIFACT_TYPE, ZIP) + + +class TestUpdateBuiltResource(TestCase): + def test_ecs_task_definition_updates_first_container(self): + properties = {"ContainerDefinitions": [{"Name": "web", "Image": "placeholder"}]} + ApplicationBuilder._update_built_resource("myimage:latest", properties, AWS_ECS_TASK_DEFINITION, "/path") + self.assertEqual(properties["ContainerDefinitions"][0]["Image"], "myimage:latest") + + def test_ecs_task_definition_empty_container_defs(self): + properties = {"ContainerDefinitions": []} + # Should not raise + ApplicationBuilder._update_built_resource("myimage:latest", properties, AWS_ECS_TASK_DEFINITION, "/path") + + def test_ecs_task_definition_targets_container_by_name(self): + properties = { + "ContainerDefinitions": [ + {"Name": "sidecar", "Image": "sidecar:latest"}, + {"Name": "web", "Image": "placeholder"}, + ] + } + metadata = {"ContainerName": "web"} + ApplicationBuilder._update_built_resource( + "myimage:latest", properties, AWS_ECS_TASK_DEFINITION, "/path", metadata + ) + self.assertEqual(properties["ContainerDefinitions"][0]["Image"], "sidecar:latest") # unchanged + self.assertEqual(properties["ContainerDefinitions"][1]["Image"], "myimage:latest") # updated + + def test_ecs_task_definition_falls_back_to_first_without_container_name(self): + properties = { + "ContainerDefinitions": [ + {"Name": "web", "Image": "placeholder"}, + {"Name": "sidecar", "Image": "sidecar:latest"}, + ] + } + ApplicationBuilder._update_built_resource("myimage:latest", properties, AWS_ECS_TASK_DEFINITION, "/path") + self.assertEqual(properties["ContainerDefinitions"][0]["Image"], "myimage:latest") + self.assertEqual(properties["ContainerDefinitions"][1]["Image"], "sidecar:latest") + + def test_agentcore_updates_nested_container_uri(self): + properties = {"AgentRuntimeArtifact": {"ContainerConfiguration": {"ContainerUri": "placeholder"}}} + ApplicationBuilder._update_built_resource("myimage:latest", properties, AWS_BEDROCK_AGENTCORE_RUNTIME, "/path") + self.assertEqual(properties["AgentRuntimeArtifact"]["ContainerConfiguration"]["ContainerUri"], "myimage:latest") + + def test_agentcore_creates_nested_structure_if_missing(self): + properties = {} + ApplicationBuilder._update_built_resource("myimage:latest", properties, AWS_BEDROCK_AGENTCORE_RUNTIME, "/path") + self.assertEqual(properties["AgentRuntimeArtifact"]["ContainerConfiguration"]["ContainerUri"], "myimage:latest") + + +class TestBuildContainerImages(TestCase): + @patch.object(ApplicationBuilder, "_build_lambda_image", return_value="myimage:latest") + def test_builds_all_definitions(self, mock_build): + builder = ApplicationBuilder.__new__(ApplicationBuilder) + builder._base_dir = "/base" + builder._stream_writer = MagicMock() + builder._use_buildkit = False + builder._image_build_client = None + + defs = [ + ContainerBuildDefinition("Task1", AWS_ECS_TASK_DEFINITION, {"Dockerfile": "D", "DockerContext": "."}), + ContainerBuildDefinition( + "Agent1", AWS_BEDROCK_AGENTCORE_RUNTIME, {"Dockerfile": "D", "DockerContext": "."} + ), + ] + result = builder.build_container_images(defs) + self.assertEqual(len(result), 2) + self.assertIn("Task1", result) + self.assertIn("Agent1", result) + self.assertEqual(mock_build.call_count, 2) + + @patch.object(ApplicationBuilder, "_build_lambda_image", return_value="img:tag") + def test_skips_definition_without_metadata(self, mock_build): + builder = ApplicationBuilder.__new__(ApplicationBuilder) + builder._base_dir = "/base" + builder._stream_writer = MagicMock() + builder._use_buildkit = False + builder._image_build_client = None + + defs = [ContainerBuildDefinition("Task1", AWS_ECS_TASK_DEFINITION, None)] + result = builder.build_container_images(defs) + self.assertEqual(len(result), 0) + mock_build.assert_not_called() + + +class TestSyncFlowFactoryMapping(TestCase): + def test_ecs_task_definition_registered(self): + self.assertIn(AWS_ECS_TASK_DEFINITION, SyncFlowFactory.GENERATOR_MAPPING) + + def test_agentcore_registered(self): + self.assertIn(AWS_BEDROCK_AGENTCORE_RUNTIME, SyncFlowFactory.GENERATOR_MAPPING) + + def test_ecs_and_agentcore_use_same_flow_creator(self): + self.assertEqual( + SyncFlowFactory.GENERATOR_MAPPING[AWS_ECS_TASK_DEFINITION], + SyncFlowFactory.GENERATOR_MAPPING[AWS_BEDROCK_AGENTCORE_RUNTIME], + ) + + +class TestSyncEcrStackIncludesContainerResources(TestCase): + @patch("samcli.lib.providers.sam_container_provider.SamContainerServiceProvider") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamFunctionProvider") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamLocalStackProvider") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.CompanionStackManager") + def test_includes_container_services( + self, mock_manager_cls, mock_stack_provider, mock_func_provider, mock_container_provider + ): + from samcli.lib.bootstrap.companion_stack.companion_stack_manager import sync_ecr_stack + + # Setup mocks + mock_stack_provider.get_stacks.return_value = ([MagicMock()], None) + + mock_func = MagicMock() + mock_func.packagetype = IMAGE + mock_func.full_path = "MyFunction" + mock_func_provider.return_value.get_all.return_value = [mock_func] + + mock_service = MagicMock() + mock_service.full_path = "MyAgent" + mock_container_provider.return_value.get_all.return_value = [mock_service] + + mock_manager = MagicMock() + mock_manager.get_repository_mapping.return_value = {"MyFunction": "uri1", "MyAgent": "uri2"} + mock_manager_cls.return_value = mock_manager + + result = sync_ecr_stack("template.yaml", "stack", "us-east-1", "bucket", "prefix", {}) + + # Verify both function and container service were passed + call_args = mock_manager.set_functions.call_args[0] + logical_ids = call_args[0] + self.assertIn("MyFunction", logical_ids) + self.assertIn("MyAgent", logical_ids) + + +class TestECSContainerSyncFlow(TestCase): + def test_sync_state_identifier(self): + from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow + + flow = ECSContainerSyncFlow.__new__(ECSContainerSyncFlow) + flow._resource_identifier = "MyTask" + self.assertEqual(flow.sync_state_identifier, "MyTask") + + def test_equality_keys(self): + from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow + + flow = ECSContainerSyncFlow.__new__(ECSContainerSyncFlow) + flow._resource_identifier = "MyTask" + self.assertEqual(flow._equality_keys(), "MyTask") + + def test_gather_dependencies_empty(self): + from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow + + flow = ECSContainerSyncFlow.__new__(ECSContainerSyncFlow) + self.assertEqual(flow.gather_dependencies(), []) + + def test_compare_remote_always_false(self): + from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow + + flow = ECSContainerSyncFlow.__new__(ECSContainerSyncFlow) + self.assertFalse(flow.compare_remote()) + + @patch("samcli.lib.sync.flows.ecs_container_sync_flow.ECRUploader") + @patch("samcli.lib.sync.flows.ecs_container_sync_flow.get_validated_container_client") + def test_sync_pushes_image(self, mock_docker, mock_ecr_uploader_cls): + from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow + + flow = ECSContainerSyncFlow.__new__(ECSContainerSyncFlow) + flow._resource_identifier = "MyAgent" + flow._log_name = "ECSContainer MyAgent" + flow._image_name = "myimage:latest" + flow._docker_client = None + flow._ecr_client = None + flow._ecs_client = None + flow._physical_id_mapping = {} + flow._deploy_context = MagicMock() + flow._deploy_context.image_repository = "123.dkr.ecr.us-east-1.amazonaws.com/repo" + flow._deploy_context.image_repositories = None + + mock_docker.return_value = MagicMock() + + flow._get_session = MagicMock() + flow._boto_client = MagicMock() + + flow.sync() + + mock_ecr_uploader_cls.assert_called_once() + mock_ecr_uploader_cls.return_value.upload.assert_called_once_with("myimage:latest", "MyAgent") + + def test_sync_skips_when_no_image(self): + from samcli.lib.sync.flows.ecs_container_sync_flow import ECSContainerSyncFlow + + flow = ECSContainerSyncFlow.__new__(ECSContainerSyncFlow) + flow._resource_identifier = "MyAgent" + flow._log_name = "ECSContainer MyAgent" + flow._image_name = None + flow._physical_id_mapping = {} + # Should not raise + flow.sync() diff --git a/tests/unit/lib/providers/test_sam_container_provider.py b/tests/unit/lib/providers/test_sam_container_provider.py new file mode 100644 index 00000000000..a9f64a2857d --- /dev/null +++ b/tests/unit/lib/providers/test_sam_container_provider.py @@ -0,0 +1,141 @@ +"""Tests for SamContainerServiceProvider""" + +from unittest import TestCase +from unittest.mock import MagicMock + +from samcli.lib.providers.sam_container_provider import SamContainerServiceProvider, CONTAINER_IMAGE_RESOURCE_TYPES +from samcli.lib.utils.resources import AWS_BEDROCK_AGENTCORE_RUNTIME, AWS_ECS_TASK_DEFINITION + + +def _make_stack(resources, stack_path=""): + stack = MagicMock() + stack.resources = resources + stack.stack_path = stack_path + return stack + + +class TestSamContainerServiceProvider(TestCase): + def test_discovers_ecs_task_definition(self): + resources = { + "MyTask": { + "Type": AWS_ECS_TASK_DEFINITION, + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./app"}, + "Properties": {"ContainerDefinitions": [{"Name": "web", "Image": "placeholder"}]}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 1) + self.assertEqual(services[0].resource_id, "MyTask") + self.assertEqual(services[0].resource_type, AWS_ECS_TASK_DEFINITION) + + def test_discovers_agentcore_runtime(self): + resources = { + "MyAgent": { + "Type": AWS_BEDROCK_AGENTCORE_RUNTIME, + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./agent"}, + "Properties": {"AgentRuntimeArtifact": {"ContainerConfiguration": {"ContainerUri": "placeholder"}}}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 1) + self.assertEqual(services[0].resource_id, "MyAgent") + self.assertEqual(services[0].resource_type, AWS_BEDROCK_AGENTCORE_RUNTIME) + + def test_skips_resource_without_metadata(self): + resources = { + "MyTask": { + "Type": AWS_ECS_TASK_DEFINITION, + "Properties": {"ContainerDefinitions": [{"Name": "web", "Image": "some-image"}]}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 0) + + def test_skips_resource_without_dockerfile(self): + resources = { + "MyTask": { + "Type": AWS_ECS_TASK_DEFINITION, + "Metadata": {"DockerContext": "./app"}, # Missing Dockerfile + "Properties": {}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 0) + + def test_skips_resource_without_docker_context(self): + resources = { + "MyTask": { + "Type": AWS_ECS_TASK_DEFINITION, + "Metadata": {"Dockerfile": "Dockerfile"}, # Missing DockerContext + "Properties": {}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 0) + + def test_skips_unsupported_resource_type(self): + resources = { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./app"}, + "Properties": {}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 0) + + def test_get_by_full_path(self): + resources = { + "MyAgent": { + "Type": AWS_BEDROCK_AGENTCORE_RUNTIME, + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./agent"}, + "Properties": {}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + result = provider.get("MyAgent") + self.assertIsNotNone(result) + self.assertEqual(result.resource_id, "MyAgent") + + def test_get_returns_none_for_unknown(self): + provider = SamContainerServiceProvider([_make_stack({})]) + self.assertIsNone(provider.get("NonExistent")) + + def test_nested_stack_full_path(self): + resources = { + "MyTask": { + "Type": AWS_ECS_TASK_DEFINITION, + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./app"}, + "Properties": {}, + } + } + provider = SamContainerServiceProvider([_make_stack(resources, stack_path="ChildStack")]) + services = list(provider.get_all()) + self.assertEqual(services[0].full_path, "ChildStack/MyTask") + + def test_multiple_resources(self): + resources = { + "Task1": { + "Type": AWS_ECS_TASK_DEFINITION, + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./app1"}, + "Properties": {}, + }, + "Agent1": { + "Type": AWS_BEDROCK_AGENTCORE_RUNTIME, + "Metadata": {"Dockerfile": "Dockerfile", "DockerContext": "./agent"}, + "Properties": {}, + }, + } + provider = SamContainerServiceProvider([_make_stack(resources)]) + services = list(provider.get_all()) + self.assertEqual(len(services), 2) + + def test_container_image_resource_types_list(self): + self.assertIn(AWS_ECS_TASK_DEFINITION, CONTAINER_IMAGE_RESOURCE_TYPES) + self.assertIn(AWS_BEDROCK_AGENTCORE_RUNTIME, CONTAINER_IMAGE_RESOURCE_TYPES) From 56c763ea37b11b634d0fd623f1277a6960ac00f2 Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Mon, 18 May 2026 11:06:18 +0300 Subject: [PATCH 2/6] fix: resolve ruff PLR2004 lint error and Windows integration test failure - Extract magic number 3 to _ECS_SERVICE_ARN_PARTS constant - Switch test Dockerfile to public.ecr.aws alpine (multi-arch, no rate limits) --- samcli/lib/sync/flows/ecs_container_sync_flow.py | 6 +++++- .../testdata/buildcmd/container_image/agent/Dockerfile | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/samcli/lib/sync/flows/ecs_container_sync_flow.py b/samcli/lib/sync/flows/ecs_container_sync_flow.py index 4d4149525be..34fedaa225a 100644 --- a/samcli/lib/sync/flows/ecs_container_sync_flow.py +++ b/samcli/lib/sync/flows/ecs_container_sync_flow.py @@ -21,6 +21,10 @@ LOG = logging.getLogger(__name__) +# Minimum number of parts expected when splitting an ECS service ARN by "/" +# e.g. arn:aws:ecs:region:account:service/cluster/name -> [..., "cluster", "name"] +_ECS_SERVICE_ARN_PARTS = 3 + class ECSContainerSyncFlow(SyncFlow): """SyncFlow for ECS TaskDefinition and AgentCore container image resources. @@ -174,7 +178,7 @@ def _force_ecs_deployment(self) -> None: try: # Extract cluster and service from the ARN parts = resource_physical_id.rsplit("/", 2) - if len(parts) < 3: + if len(parts) < _ECS_SERVICE_ARN_PARTS: continue cluster = parts[-2] service_name = parts[-1] diff --git a/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile b/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile index 57f45c64404..70947c4a8ab 100644 --- a/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile +++ b/tests/integration/testdata/buildcmd/container_image/agent/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM public.ecr.aws/docker/library/alpine:3.19 WORKDIR /app RUN echo "hello" > /app/test.txt -CMD ["python", "-c", "print('test')"] +CMD ["echo", "test"] From ec83c234a9ca66dcab37fb37b912314e5718d185 Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Mon, 18 May 2026 12:02:36 +0300 Subject: [PATCH 3/6] fix: skip container image integration test on Windows CI Windows CI runners use Windows containers and cannot pull Linux images. This matches the pattern used by existing Lambda image build tests. --- .../buildcmd/test_build_cmd_container_image.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration/buildcmd/test_build_cmd_container_image.py b/tests/integration/buildcmd/test_build_cmd_container_image.py index c34bd7cab90..56a2136f157 100644 --- a/tests/integration/buildcmd/test_build_cmd_container_image.py +++ b/tests/integration/buildcmd/test_build_cmd_container_image.py @@ -1,16 +1,20 @@ """Integration test for ECS/AgentCore container image builds""" -import os import shutil import tempfile from pathlib import Path -from unittest import TestCase +from unittest import TestCase, skipIf import yaml from samcli.commands.build.build_context import BuildContext +from tests.testing_utils import CI_OVERRIDE, IS_WINDOWS, RUNNING_ON_CI +@skipIf( + (IS_WINDOWS and RUNNING_ON_CI) and not CI_OVERRIDE, + "Skip container image build tests on Windows CI (no Linux Docker support)", +) class TestContainerImageBuild(TestCase): """Test that samdev build correctly handles ECS and AgentCore container resources.""" From 6ca8f92b81ff93e1791f6447a7739c0dc64a7d36 Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Tue, 19 May 2026 02:46:29 +0300 Subject: [PATCH 4/6] fix: address PR reviewer findings - remove substring false-positive and add error case tests - Remove substring check in _force_ecs_deployment that could match wrong services (e.g. my-app:5 matching my-app:50). Use family-name comparison only. - Add unit tests for ContainerName mismatch error handling in both app_builder and packageable_resources. - Clean up unused imports in test file. --- .../lib/sync/flows/ecs_container_sync_flow.py | 8 ++-- .../test_container_build_integration.py | 43 +++++++++++++++++-- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/samcli/lib/sync/flows/ecs_container_sync_flow.py b/samcli/lib/sync/flows/ecs_container_sync_flow.py index 34fedaa225a..f62aeb138fb 100644 --- a/samcli/lib/sync/flows/ecs_container_sync_flow.py +++ b/samcli/lib/sync/flows/ecs_container_sync_flow.py @@ -184,13 +184,11 @@ def _force_ecs_deployment(self) -> None: service_name = parts[-1] svc_response = ecs_client.describe_services(cluster=cluster, services=[service_name]) + my_family = physical_id.rsplit("/", 1)[-1].split(":", 1)[0] for svc in svc_response.get("services", []): svc_task_def = svc.get("taskDefinition", "") - # Check if this service references our task definition family - if physical_id in svc_task_def or ( - svc_task_def.rsplit("/", 1)[-1].split(":", 1)[0] - == physical_id.rsplit("/", 1)[-1].split(":", 1)[0] - ): + svc_family = svc_task_def.rsplit("/", 1)[-1].split(":", 1)[0] + if svc_family and svc_family == my_family: ecs_client.update_service( cluster=cluster, service=service_name, diff --git a/tests/unit/lib/build_module/test_container_build_integration.py b/tests/unit/lib/build_module/test_container_build_integration.py index fcc800c3a89..b86c3857106 100644 --- a/tests/unit/lib/build_module/test_container_build_integration.py +++ b/tests/unit/lib/build_module/test_container_build_integration.py @@ -1,8 +1,7 @@ """Tests for ECS/AgentCore container build integration across modules""" from unittest import TestCase -from unittest.mock import MagicMock, patch, Mock -from copy import deepcopy +from unittest.mock import MagicMock, patch from samcli.lib.build.app_builder import ApplicationBuilder from samcli.lib.build.build_graph import ContainerBuildDefinition @@ -184,7 +183,7 @@ def test_includes_container_services( mock_manager.get_repository_mapping.return_value = {"MyFunction": "uri1", "MyAgent": "uri2"} mock_manager_cls.return_value = mock_manager - result = sync_ecr_stack("template.yaml", "stack", "us-east-1", "bucket", "prefix", {}) + sync_ecr_stack("template.yaml", "stack", "us-east-1", "bucket", "prefix", {}) # Verify both function and container service were passed call_args = mock_manager.set_functions.call_args[0] @@ -257,3 +256,41 @@ def test_sync_skips_when_no_image(self): flow._physical_id_mapping = {} # Should not raise flow.sync() + + +class TestContainerNameErrorHandling(TestCase): + def test_update_built_resource_raises_on_container_name_mismatch(self): + from samcli.lib.build.exceptions import DockerBuildFailed + + properties = { + "ContainerDefinitions": [ + {"Name": "sidecar", "Image": "sidecar:latest"}, + {"Name": "web", "Image": "placeholder"}, + ] + } + metadata = {"ContainerName": "typo"} + with self.assertRaises(DockerBuildFailed): + ApplicationBuilder._update_built_resource( + "myimage:latest", properties, AWS_ECS_TASK_DEFINITION, "/path", metadata + ) + + def test_get_target_index_raises_on_container_name_mismatch(self): + from samcli.commands.package import exceptions + + exporter = ECSTaskDefinitionImageResource.__new__(ECSTaskDefinitionImageResource) + exporter.resource_metadata = {"ContainerName": "typo"} + container_defs = [{"Name": "web"}, {"Name": "sidecar"}] + with self.assertRaises(exceptions.ExportFailedError): + exporter._get_target_index(container_defs) + + def test_get_target_index_returns_match(self): + exporter = ECSTaskDefinitionImageResource.__new__(ECSTaskDefinitionImageResource) + exporter.resource_metadata = {"ContainerName": "web"} + container_defs = [{"Name": "sidecar"}, {"Name": "web"}] + self.assertEqual(exporter._get_target_index(container_defs), 1) + + def test_get_target_index_defaults_to_zero_without_name(self): + exporter = ECSTaskDefinitionImageResource.__new__(ECSTaskDefinitionImageResource) + exporter.resource_metadata = {} + container_defs = [{"Name": "web"}] + self.assertEqual(exporter._get_target_index(container_defs), 0) From 977701d32f7674af1d0983a6740f1fa88d50f1a6 Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Tue, 19 May 2026 15:48:03 +0300 Subject: [PATCH 5/6] ci: retrigger CI (pyinstaller-linux infra flake) From 8d1a612e9898edde3f0b28448a2aa4111c7dddd2 Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Tue, 19 May 2026 16:55:33 +0300 Subject: [PATCH 6/6] ci: retrigger CI (go setup infra flake)