Skip to content

Commit c1dab5f

Browse files
DUPLO-38290 fix: add comprehensive property mapping for ECS task definition (#191)
* DUPLO-38290 fix: comprehensive mapping for task definition and container definition models based on AWS models * DUPLO-38290 chore: rename volume sanitizer helper * DUPLO-38290 chore: clean up unused snippet * DUPLO-38290 chore: update changelog * DUPLO-38290 test: add dedicated test for internal taskdef mapping helper * DUPLO-38290 chore: refine changes * DUPLO-38290 test: more coverage for mapping logic * DUPLO-38345 fix: decouple find_def method from ECS services * DUPLO-38345 chore: update docs to better reflect usage intent for run_task command * DUPLO-38345 revert: DUPLO-35823 * DUPLO-38345 chore: update changelog --------- Co-authored-by: Kelly Ferrone <[email protected]>
1 parent abd0100 commit c1dab5f

File tree

3 files changed

+164
-82
lines changed

3 files changed

+164
-82
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- updating image for an ecs task definition ignored existing defined volumes when mapping to new payload
13+
- updating image for an ECS service failed to comprehensively map existing payload properties to new task definition version
14+
- reverts DUPLO-35823 and adds clarifications to duploctl ecs commands making intended usage clearer
1315

1416
### Added
1517

src/duplo_resource/ecs_service.py

Lines changed: 44 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -94,22 +94,20 @@ def list_task_def_family(self) -> dict:
9494
@Command()
9595
def find_def(self,
9696
name: args.NAME):
97-
"""Find a ECS task definition by name.
97+
"""Find the latest version of an ECS task definition by family name.
9898
9999
Args:
100-
name: The name of the ECS task definition to find.
100+
name: The family name of the ECS task definition to find.
101101
102102
Returns:
103-
task_definition: The ECS task definition object.
103+
task_definition: The latest version of that ECS task definition in the family.
104104
105105
Raises:
106106
DuploError: If the ECS task definition could not be found.
107107
"""
108108
name = self.prefixed_name(name)
109-
svcFam = self.find_service_family(name)
110-
family = svcFam["TaskDefFamily"]
111-
tdf = self.find_task_def_family(family)
112-
arn = tdf["VersionArns"][-1]
109+
task_definition_family = self.find_task_def_family(name)
110+
arn = task_definition_family["VersionArns"][-1]
113111
return self.find_def_by_arn(arn)
114112

115113
@Command()
@@ -199,14 +197,16 @@ def update_image(self,
199197
container_image: args.CONTAINER_IMAGE = None) -> dict:
200198
"""Update Image
201199
202-
Update the image for an ECS task definition and service container if one exists.
200+
Creates a new task definition version cloning the latest existing version in the family except for image arguments
201+
202+
If task family is used by an ECS service, method also updates the service to use that newly created definition version
203203
204204
Usage: Basic CLI Use
205205
```sh
206-
duploctl ecs update_image <service-name> <service-image>
206+
duploctl ecs update_image <task-definition-family-name> <new-image>
207207
```
208208
```sh
209-
duploctl ecs update_image <service-name> --container-image <container-name> <container-image>
209+
duploctl ecs update_image <task-definition-family-name> --container-image <container-name> <new-container-image>
210210
```
211211
212212
Example: Update image and wait
@@ -217,15 +217,15 @@ def update_image(self,
217217
```
218218
219219
Args:
220-
name: The name of the ECS service to update.
220+
name: The name of the ECS task definition family to update.
221221
image: The new image to use for the container.
222222
container-image: A list of key-value pairs to set as container image.
223223
224224
Returns:
225225
dict: A dictionary containing a message about the update status.
226226
227227
Raises:
228-
DuploError: If the ECS service could not be updated.
228+
DuploError: If the ECS task definition family could not be updated.
229229
"""
230230
name = self.prefixed_name(name)
231231
tdf = self.find_def(name)
@@ -255,12 +255,22 @@ def update_image(self,
255255
}
256256

257257
def __ecs_task_def_body(self, task_def):
258-
containers = [
259-
self.__ecs_container_update_body(c)
260-
for c in task_def.get("ContainerDefinitions", [])
261-
]
258+
def sanitize_container_definition(containerDefinition):
259+
if containerDefinition.get("Cpu") == 0:
260+
del containerDefinition["Cpu"]
261+
if containerDefinition.get("Memory") == 0:
262+
del containerDefinition["Memory"]
263+
if containerDefinition.get("MemoryReservation") == 0:
264+
del containerDefinition["MemoryReservation"]
265+
if containerDefinition.get("StartTimeout") == 0:
266+
del containerDefinition["StartTimeout"]
267+
if containerDefinition.get("StopTimeout") == 0:
268+
del containerDefinition["StopTimeout"]
269+
return containerDefinition
270+
271+
containers = list(map(sanitize_container_definition, task_def.get("ContainerDefinitions", [])))
262272

263-
def sanitize_efs_port_if_needed(v):
273+
def sanitize_volume(v):
264274
if "EfsVolumeConfiguration" in v and "TransitEncryptionPort" in v["EfsVolumeConfiguration"] and v["EfsVolumeConfiguration"]["TransitEncryptionPort"] == 0:
265275
del v["EfsVolumeConfiguration"]["TransitEncryptionPort"]
266276
return v
@@ -272,44 +282,28 @@ def sanitize_efs_port_if_needed(v):
272282
"ContainerDefinitions": containers,
273283
"RuntimePlatform": task_def.get("RuntimePlatform", {}),
274284
"RequiresCompatibilities": task_def.get("RequiresCompatibilities", []),
275-
"Volumes": list(map(sanitize_efs_port_if_needed, task_def.get("Volumes", []))),
285+
"Volumes": list(map(sanitize_volume, task_def.get("Volumes", []))),
276286
}
277287
if task_def.get("Cpu") not in (None, 0):
278288
result["Cpu"] = task_def["Cpu"]
279289
if task_def.get("Memory") not in (None, 0):
280290
result["Memory"] = task_def["Memory"]
281291
if task_def.get("MemoryReservation") not in (None, 0):
282292
result["MemoryReservation"] = task_def["MemoryReservation"]
283-
return result
284-
293+
if "EphemeralStorage" in task_def:
294+
result["EphemeralStorage"] = task_def["EphemeralStorage"]
295+
if "ExecutionRoleArn" in task_def:
296+
result["ExecutionRoleArn"] = task_def["ExecutionRoleArn"]
297+
if "IpcMode" in task_def:
298+
result["IpcMode"] = task_def["IpcMode"]
299+
if "PlacementConstraints" in task_def:
300+
result["PlacementConstraints"] = task_def["PlacementConstraints"]
301+
if "ProxyConfiguration" in task_def:
302+
result["ProxyConfiguration"] = task_def["ProxyConfiguration"]
303+
if "TaskRoleArn" in task_def:
304+
result["TaskRoleArn"] = task_def["TaskRoleArn"]
285305

286-
def __ecs_container_update_body(self, container_def):
287-
update_body = {
288-
"Essential": container_def.get("Essential"),
289-
"Image": container_def.get("Image") ,
290-
"Name": container_def.get("Name") ,
291-
"PortMappings": container_def.get("PortMappings", []) ,
292-
"Environment": container_def.get("Environment", {}) ,
293-
"Command": container_def.get("Command", {}) ,
294-
"Secrets": container_def.get("Secrets", {}) ,
295-
"EnvironmentFiles": container_def.get("EnvironmentFiles", {})
296-
}
297-
298-
# Add LogConfiguration only if it exists in container_def
299-
if "LogConfiguration" in container_def:
300-
update_body["LogConfiguration"] = container_def["LogConfiguration"]
301-
# Add FirelensConfiguration only if it exists in container_def
302-
if "FirelensConfiguration" in container_def:
303-
update_body["FirelensConfiguration"] = container_def["FirelensConfiguration"]
304-
if container_def.get("Cpu") not in (None, 0):
305-
update_body["Cpu"] = container_def["Cpu"]
306-
if container_def.get("Memory") not in (None, 0):
307-
update_body["Memory"] = container_def["Memory"]
308-
if container_def.get("MemoryReservation") not in (None, 0):
309-
update_body["MemoryReservation"] = container_def["MemoryReservation"]
310-
if container_def.get("RepositoryCredentials") not in (None, 0):
311-
update_body["RepositoryCredentials"] = container_def["RepositoryCredentials"]
312-
return update_body
306+
return result
313307

314308
@Command()
315309
def list_tasks(self,
@@ -338,13 +332,13 @@ def list_tasks(self,
338332
def run_task(self,
339333
name: args.NAME,
340334
replicas: args.REPLICAS) -> dict:
341-
"""Run a task for an ECS service."
335+
"""Run a task from an ECS task definition family's latest definition version."
342336
343337
Execute a task based on some definition.
344338
345339
Usage: Basic CLI Use
346340
```sh
347-
duploctl ecs run_task <service-name> <replicas>
341+
duploctl ecs run_task <task-definition-family-name> <replicas>
348342
```
349343
350344
Example: Wait for task to complete
@@ -355,7 +349,7 @@ def run_task(self,
355349
```
356350
357351
Args:
358-
name: The name of the ECS service to run the task for.
352+
name: The name of the ECS task definition family the task will be spawned from.
359353
replicas: The number of replicas to run.
360354
361355
Returns:

src/tests/test_ecs_service.py

Lines changed: 118 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from unittest.mock import ANY, MagicMock
33
from duplo_resource.ecs_service import DuploEcsService
4+
from duplocloud.client import DuploClient
45
from duplocloud.errors import DuploError
56
from tests.test_utils import execute_test, assert_response
67

@@ -46,20 +47,7 @@ def test_update_image_without_container(mocker):
4647
# Mock task definition with single container
4748
mock_task_def = {
4849
"ContainerDefinitions": [
49-
{"Name": "app", "Image": "old-image:1"}
50-
],
51-
"Volumes": [
52-
{
53-
"ConfiguredAtLaunch": False,
54-
"EfsVolumeConfiguration": {
55-
"FileSystemId": "<my-efs-id>",
56-
"RootDirectory": "/",
57-
"TransitEncryption": {
58-
"Value": "ENABLED"
59-
}
60-
},
61-
"Name": "my-volume"
62-
}
50+
{ "Name": "app", "Image": "old-image:1" }
6351
]
6452
}
6553
# Mock service family
@@ -108,26 +96,124 @@ def test_update_image_no_service(mocker):
10896
assert_response(result, "No Service Configured, only the definition is updated.")
10997

11098
@pytest.mark.unit
111-
def test_find_def_uses_taskdef_family_from_service(mocker):
99+
def test_taskdef_mapping_comprehensive_enough(mocker):
100+
mock_client = mocker.MagicMock()
101+
service = DuploEcsService(mock_client)
102+
103+
existing_taskdef = {
104+
"Family": "my-family",
105+
"ContainerDefinitions": [
106+
{
107+
"Name": "app",
108+
"Image": "my-image:1",
109+
"CredentialSpecs" : [],
110+
"Environment" : [
111+
{
112+
"Name" : "ENV_VAR1",
113+
"Value" : "VALUE1"
114+
},
115+
{
116+
"Name" : "ENV_VAR2",
117+
"Value" : "VALUE2"
118+
},
119+
],
120+
"EnvironmentFiles" : [
121+
{
122+
"Type" : {
123+
"Value" : "s3"
124+
},
125+
"Value" : "arn:aws:s3:::my-example-bucket/sample.env"
126+
}
127+
],
128+
"LogConfiguration" : {
129+
"LogDriver" : {
130+
"Value" : "dummy-driver"
131+
},
132+
"Options" : {
133+
"OptionKey1": "OptionValue1",
134+
"OptionKey2": "OptionValue2"
135+
},
136+
"SecretOptions" : []
137+
},
138+
"stopTimeout" : 120,
139+
"MountPoints" : [
140+
{
141+
"ContainerPath" : "/mnt/my-volume",
142+
"ReadOnly" : "false",
143+
"SourceVolume" : "my-volume"
144+
}
145+
]
146+
}
147+
],
148+
"Volumes": [
149+
{
150+
"ConfiguredAtLaunch": False,
151+
"EfsVolumeConfiguration": {
152+
"FileSystemId": "<my-efs-id>",
153+
"RootDirectory": "/",
154+
"TransitEncryption": {
155+
"Value": "ENABLED"
156+
}
157+
},
158+
"Name": "my-volume"
159+
}
160+
]
161+
}
162+
163+
# Even though this is a private method and should not be part of a behavior driven test suite
164+
# Issues with this method has caused enough problems to justify this extra coverage
165+
result = execute_test(service._DuploEcsService__ecs_task_def_body, existing_taskdef)
166+
167+
assert result["Volumes"] == existing_taskdef["Volumes"]
168+
assert result["Family"] == existing_taskdef["Family"]
169+
assert result["ContainerDefinitions"] == existing_taskdef["ContainerDefinitions"]
170+
171+
@pytest.mark.unit
172+
def test_taskdef_mapping_properly_sanitizes_properties(mocker):
112173
mock_client = mocker.MagicMock()
113174
service = DuploEcsService(mock_client)
175+
existing_taskdef = {
176+
"Family": "my-family",
177+
"ContainerDefinitions": [
178+
{
179+
"Name": "app",
180+
"Image": "my-image:1",
181+
"Cpu": 0,
182+
"Memory": 0,
183+
"MemoryReservation": 0,
184+
"StartTimeout": 0,
185+
"StopTimeout": 0,
186+
"MountPoints" : [
187+
{
188+
"ContainerPath" : "/mnt/my-volume",
189+
"ReadOnly" : "false",
190+
"SourceVolume" : "my-volume"
191+
}
192+
]
193+
}
194+
],
195+
"Volumes": [
196+
{
197+
"ConfiguredAtLaunch": False,
198+
"EfsVolumeConfiguration": {
199+
"FileSystemId": "<my-efs-id>",
200+
"RootDirectory": "/",
201+
"TransitEncryption": {
202+
"Value": "ENABLED"
203+
},
204+
"TransitEncryptionPort": 0
205+
},
206+
"Name": "my-volume"
207+
}
208+
]
209+
}
114210

115-
# Arrange
116-
mocker.patch.object(service, 'prefixed_name', return_value='prefixed-svc')
117-
mocker.patch.object(service, 'find_service_family', return_value={
118-
'TaskDefFamily': 'actual-family'
119-
})
120-
mock_find_tdf = mocker.patch.object(service, 'find_task_def_family', return_value={
121-
'VersionArns': ['arn:tdf:1', 'arn:tdf:2']
122-
})
123-
mock_find_by_arn = mocker.patch.object(service, 'find_def_by_arn', return_value={
124-
'TaskDefinitionArn': 'arn:tdf:2'
125-
})
211+
result = execute_test(service._DuploEcsService__ecs_task_def_body, existing_taskdef)
126212

127-
# Act
128-
result = execute_test(service.find_def, 'svc-name')
213+
assert "Cpu" not in result["ContainerDefinitions"][0]
214+
assert "Memory" not in result["ContainerDefinitions"][0]
215+
assert "MemoryReservation" not in result["ContainerDefinitions"][0]
216+
assert "StartTimeout" not in result["ContainerDefinitions"][0]
217+
assert "StopTimeout" not in result["ContainerDefinitions"][0]
129218

130-
# Assert
131-
mock_find_tdf.assert_called_once_with('actual-family')
132-
mock_find_by_arn.assert_called_once_with('arn:tdf:2')
133-
assert result == {'TaskDefinitionArn': 'arn:tdf:2'}
219+
assert "TransitEncryptionPort" not in result["Volumes"][0]["EfsVolumeConfiguration"]

0 commit comments

Comments
 (0)