2121
2222LOG = logging .getLogger (__name__ )
2323
24- # Minimum number of parts expected when splitting an ECS service ARN by "/"
24+ # ECS service ARN format: arn:aws:ecs:<region>:<account>:service/<cluster>/<name>
25+ _ECS_SERVICE_ARN_PREFIX = "arn:aws:ecs:"
26+ _ECS_SERVICE_ARN_SERVICE_SEGMENT = ":service/"
27+
28+ # Minimum number of slash-separated parts after splitting a service ARN
2529# e.g. arn:aws:ecs:region:account:service/cluster/name -> [..., "cluster", "name"]
2630_ECS_SERVICE_ARN_PARTS = 3
2731
2832
2933class ECSContainerSyncFlow (SyncFlow ):
30- """SyncFlow for ECS TaskDefinition and AgentCore container image resources.
34+ """SyncFlow for ECS TaskDefinition container image resources.
3135
32- Builds the container image, pushes to ECR, and triggers an ECS service update.
36+ Builds the container image, pushes to ECR, registers a new TaskDefinition
37+ revision with the updated image URI, then updates any associated ECS services
38+ to that revision.
3339 """
3440
3541 _resource_identifier : str
@@ -131,7 +137,6 @@ def sync(self) -> None:
131137 LOG .debug ("%sSkipping sync. Image name is None." , self .log_prefix )
132138 return
133139
134- # Get ECR repo from deploy context
135140 ecr_repo = self ._deploy_context .image_repository
136141 if (
137142 not ecr_repo
@@ -142,68 +147,145 @@ def sync(self) -> None:
142147
143148 if not ecr_repo :
144149 LOG .warning (
145- "%sNo ECR repository configured for %s. " " Use --image-repository or --image-repositories." ,
150+ "%sNo ECR repository configured for %s. Use --image-repository or --image-repositories." ,
146151 self .log_prefix ,
147152 self ._resource_identifier ,
148153 )
149154 return
150155
151- # Push image to ECR
152156 ecr_uploader = ECRUploader (self ._get_docker_client (), self ._get_ecr_client (), ecr_repo , None )
153- ecr_uploader .upload (self ._image_name , self ._resource_identifier )
157+ image_uri = ecr_uploader .upload (self ._image_name , self ._resource_identifier )
154158
155- # Force new deployment of ECS service if one is associated
156- self ._force_ecs_deployment ()
159+ new_task_def_arn = self ._register_updated_task_definition (image_uri )
160+ if new_task_def_arn :
161+ self ._update_services_to_task_definition (new_task_def_arn )
157162
158- def _force_ecs_deployment (self ) -> None :
159- """Force new deployment for ECS services in the stack that use this task definition ."""
163+ def _register_updated_task_definition (self , image_uri : str ) -> Optional [ str ] :
164+ """Describe the current task definition, swap the image URI, and register a new revision ."""
160165 physical_id = self ._physical_id_mapping .get (self ._resource_identifier )
161166 if not physical_id :
162- LOG .debug ("%sNo physical ID found for %s, skipping ECS update" , self .log_prefix , self ._resource_identifier )
163- return
167+ LOG .debug (
168+ "%sNo physical ID for %s; cannot register new task definition revision." ,
169+ self .log_prefix ,
170+ self ._resource_identifier ,
171+ )
172+ return None
173+
174+ ecs_client = self ._get_ecs_client ()
175+ try :
176+ response = ecs_client .describe_task_definition (taskDefinition = physical_id , include = ["TAGS" ])
177+ except ClientError :
178+ LOG .warning (
179+ "%sFailed to describe task definition %s" , self .log_prefix , physical_id , exc_info = True
180+ )
181+ return None
182+
183+ task_def = response .get ("taskDefinition" , {})
184+ tags = response .get ("tags" , [])
185+
186+ # Fields that are output-only and must be stripped before re-registering
187+ _READONLY_FIELDS = {
188+ "taskDefinitionArn" ,
189+ "revision" ,
190+ "status" ,
191+ "requiresAttributes" ,
192+ "compatibilities" ,
193+ "registeredAt" ,
194+ "registeredBy" ,
195+ }
196+ register_input = {k : v for k , v in task_def .items () if k not in _READONLY_FIELDS }
197+
198+ # Update the image in the matching container definition
199+ container_name = self ._get_container_name ()
200+ container_defs = register_input .get ("containerDefinitions" , [])
201+ updated = False
202+ for cd in container_defs :
203+ if container_name is None or cd .get ("name" ) == container_name :
204+ cd ["image" ] = image_uri
205+ updated = True
206+ if container_name is not None :
207+ break
208+
209+ if not updated :
210+ LOG .warning (
211+ "%sContainerName '%s' not found in task definition; skipping registration." ,
212+ self .log_prefix ,
213+ container_name ,
214+ )
215+ return None
216+
217+ if tags :
218+ register_input ["tags" ] = tags
164219
165220 try :
166- ecs_client = self ._get_ecs_client ()
167-
168- # Find ECS services in the same stack by looking at physical_id_mapping
169- # ECS Service physical IDs are ARNs like arn:aws:ecs:region:account:service/cluster/name
170- for resource_id , resource_physical_id in self ._physical_id_mapping .items ():
171- if not resource_physical_id or "service/" not in str (resource_physical_id ):
172- continue
173- # Check if this service uses our task definition
174- try :
175- # Extract cluster and service from the ARN
176- parts = resource_physical_id .rsplit ("/" , 2 )
177- if len (parts ) < _ECS_SERVICE_ARN_PARTS :
178- continue
179- cluster = parts [- 2 ]
180- service_name = parts [- 1 ]
181-
182- svc_response = ecs_client .describe_services (cluster = cluster , services = [service_name ])
183- my_family = physical_id .rsplit ("/" , 1 )[- 1 ].split (":" , 1 )[0 ]
184- for svc in svc_response .get ("services" , []):
185- svc_task_def = svc .get ("taskDefinition" , "" )
186- svc_family = svc_task_def .rsplit ("/" , 1 )[- 1 ].split (":" , 1 )[0 ]
187- if svc_family and svc_family == my_family :
188- ecs_client .update_service (
189- cluster = cluster ,
190- service = service_name ,
191- forceNewDeployment = True ,
192- )
193- LOG .info (
194- "%sForced new deployment for service %s" ,
195- self .log_prefix ,
196- service_name ,
197- )
198- except ClientError :
199- LOG .warning (
200- "%sFailed to update service %s" ,
201- self .log_prefix ,
202- resource_id ,
203- exc_info = True ,
204- )
221+ reg_response = ecs_client .register_task_definition (** register_input )
205222 except ClientError :
206- LOG .warning ("%sFailed to force ECS deployment" , self .log_prefix , exc_info = True )
223+ LOG .warning ("%sFailed to register new task definition revision" , self .log_prefix , exc_info = True )
224+ return None
225+
226+ new_arn = reg_response .get ("taskDefinition" , {}).get ("taskDefinitionArn" )
227+ LOG .info ("%sRegistered new task definition revision: %s" , self .log_prefix , new_arn )
228+ return new_arn
229+
230+ def _get_container_name (self ) -> Optional [str ]:
231+ """Return ContainerName from resource metadata, if set."""
232+ if not self ._stacks :
233+ return None
234+ provider = SamContainerServiceProvider (self ._stacks )
235+ service = provider .get (self ._resource_identifier )
236+ if service and isinstance (service .metadata , dict ):
237+ return service .metadata .get ("ContainerName" )
238+ return None
239+
240+ def _update_services_to_task_definition (self , task_def_arn : str ) -> None :
241+ """Update ECS services that reference this task definition family to the new revision."""
242+ physical_id = self ._physical_id_mapping .get (self ._resource_identifier )
243+ if not physical_id :
244+ return
245+
246+ ecs_client = self ._get_ecs_client ()
247+ my_family = physical_id .rsplit ("/" , 1 )[- 1 ].split (":" , 1 )[0 ]
248+
249+ for resource_id , resource_physical_id in self ._physical_id_mapping .items ():
250+ if not resource_physical_id :
251+ continue
252+ # Only consider ECS service ARNs; skip everything else (incl. App Runner, VPC Lattice, etc.)
253+ if not (
254+ str (resource_physical_id ).startswith (_ECS_SERVICE_ARN_PREFIX )
255+ and _ECS_SERVICE_ARN_SERVICE_SEGMENT in str (resource_physical_id )
256+ ):
257+ continue
258+
259+ parts = resource_physical_id .rsplit ("/" , 2 )
260+ if len (parts ) < _ECS_SERVICE_ARN_PARTS :
261+ continue
262+ cluster = parts [- 2 ]
263+ service_name = parts [- 1 ]
264+
265+ try :
266+ svc_response = ecs_client .describe_services (cluster = cluster , services = [service_name ])
267+ for svc in svc_response .get ("services" , []):
268+ svc_task_def = svc .get ("taskDefinition" , "" )
269+ svc_family = svc_task_def .rsplit ("/" , 1 )[- 1 ].split (":" , 1 )[0 ]
270+ if svc_family and svc_family == my_family :
271+ ecs_client .update_service (
272+ cluster = cluster ,
273+ service = service_name ,
274+ taskDefinition = task_def_arn ,
275+ )
276+ LOG .info (
277+ "%sUpdated service %s to task definition %s" ,
278+ self .log_prefix ,
279+ service_name ,
280+ task_def_arn ,
281+ )
282+ except ClientError :
283+ LOG .warning (
284+ "%sFailed to update service %s" ,
285+ self .log_prefix ,
286+ resource_id ,
287+ exc_info = True ,
288+ )
207289
208290 def gather_dependencies (self ) -> List [SyncFlow ]:
209291 return []
@@ -212,7 +294,7 @@ def _get_resource_api_calls(self) -> List[ResourceAPICall]:
212294 return [
213295 ResourceAPICall (
214296 self ._resource_identifier ,
215- [ApiCallTypes .UPDATE_FUNCTION_CODE ],
297+ [ApiCallTypes .UPDATE_CONTAINER_IMAGE ],
216298 )
217299 ]
218300
0 commit comments