-
Notifications
You must be signed in to change notification settings - Fork 4.3k
/
Copy pathdeploy.py
453 lines (366 loc) · 17.2 KB
/
deploy.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
import hashlib
import json
import os
import sys
from botocore import compat, config
from botocore.exceptions import ClientError
from awscli.compat import compat_open
from awscli.customizations.ecs import exceptions, filehelpers
from awscli.customizations.commands import BasicCommand
TIMEOUT_BUFFER_MIN = 10
DEFAULT_DELAY_SEC = 15
MAX_WAIT_MIN = 360 # 6 hours
class ECSDeploy(BasicCommand):
NAME = 'deploy'
DESCRIPTION = (
"Deploys a new task definition to the specified ECS service. "
"Only services that use CodeDeploy for deployments are supported. "
"This command will register a new task definition, update the "
"CodeDeploy appspec with the new task definition revision, create a "
"CodeDeploy deployment, and wait for the deployment to successfully "
"complete. This command will exit with a return code of 255 if the "
"deployment does not succeed within 30 minutes by default or "
"up to 10 minutes more than your deployment group's configured wait "
"time (max of 6 hours)."
)
ARG_TABLE = [
{
'name': 'service',
'help_text': ("The short name or full Amazon Resource Name "
"(ARN) of the service to update"),
'required': True
},
{
'name': 'task-definition',
'help_text': ("The file path where your task definition file is "
"located. The format of the file must be the same "
"as the JSON output of: <codeblock>aws ecs "
"register-task-definition "
"--generate-cli-skeleton</codeblock>"),
'required': True
},
{
'name': 'codedeploy-appspec',
'help_text': ("The file path where your AWS CodeDeploy appspec "
"file is located. The appspec file may be in JSON "
"or YAML format. The <code>TaskDefinition</code> "
"property will be updated within the appspec with "
"the newly registered task definition ARN, "
"overwriting any placeholder values in the file."),
'required': True
},
{
'name': 'cluster',
'help_text': ("The short name or full Amazon Resource Name "
"(ARN) of the cluster that your service is "
"running within. If you do not specify a "
"cluster, the \"default\" cluster is assumed."),
'required': False
},
{
'name': 'codedeploy-application',
'help_text': ("The name of the AWS CodeDeploy application "
"to use for the deployment. The specified "
"application must use the 'ECS' compute "
"platform. If you do not specify an "
"application, the application name "
"<code>AppECS-[CLUSTER_NAME]-[SERVICE_NAME]</code> "
"is assumed."),
'required': False
},
{
'name': 'codedeploy-deployment-group',
'help_text': ("The name of the AWS CodeDeploy deployment "
"group to use for the deployment. The "
"specified deployment group must be associated "
"with the specified ECS service and cluster. "
"If you do not specify a deployment group, "
"the deployment group name "
"<code>DgpECS-[CLUSTER_NAME]-[SERVICE_NAME]</code> "
"is assumed."),
'required': False
}
]
MSG_TASK_DEF_REGISTERED = \
"Successfully registered new ECS task definition {arn}\n"
MSG_CREATED_DEPLOYMENT = "Successfully created deployment {id}\n"
MSG_SUCCESS = ("Successfully deployed {task_def} to "
"service '{service}'\n")
USER_AGENT_EXTRA = 'customization/ecs-deploy'
def _run_main(self, parsed_args, parsed_globals):
register_task_def_kwargs, appspec_obj = \
self._load_file_args(parsed_args.task_definition,
parsed_args.codedeploy_appspec)
ecs_client_wrapper = ECSClient(
self._session, parsed_args, parsed_globals, self.USER_AGENT_EXTRA)
self.resources = self._get_resource_names(
parsed_args, ecs_client_wrapper)
codedeploy_client = self._session.create_client(
'codedeploy',
region_name=parsed_globals.region,
verify=parsed_globals.verify_ssl,
config=config.Config(user_agent_extra=self.USER_AGENT_EXTRA))
self._validate_code_deploy_resources(codedeploy_client)
self.wait_time = self._cd_validator.get_deployment_wait_time()
self.task_def_arn = self._register_task_def(
register_task_def_kwargs, ecs_client_wrapper)
self._create_and_wait_for_deployment(codedeploy_client, appspec_obj)
def _create_and_wait_for_deployment(self, client, appspec):
deployer = CodeDeployer(client, appspec)
deployer.update_task_def_arn(self.task_def_arn)
deployment_id = deployer.create_deployment(
self.resources['app_name'],
self.resources['deployment_group_name'])
sys.stdout.write(self.MSG_CREATED_DEPLOYMENT.format(
id=deployment_id))
deployer.wait_for_deploy_success(deployment_id, self.wait_time)
service_name = self.resources['service']
sys.stdout.write(
self.MSG_SUCCESS.format(
task_def=self.task_def_arn, service=service_name))
sys.stdout.flush()
def _get_file_contents(self, file_path):
full_path = os.path.expandvars(os.path.expanduser(file_path))
try:
with compat_open(full_path) as f:
return f.read()
except (OSError, IOError, UnicodeDecodeError) as e:
raise exceptions.FileLoadError(
file_path=file_path, error=e)
def _get_resource_names(self, args, ecs_client):
service_details = ecs_client.get_service_details()
service_name = service_details['service_name']
cluster_name = service_details['cluster_name']
application_name = filehelpers.get_app_name(
service_name, cluster_name, args.codedeploy_application)
deployment_group_name = filehelpers.get_deploy_group_name(
service_name, cluster_name, args.codedeploy_deployment_group)
return {
'service': service_name,
'service_arn': service_details['service_arn'],
'cluster': cluster_name,
'cluster_arn': service_details['cluster_arn'],
'app_name': application_name,
'deployment_group_name': deployment_group_name
}
def _load_file_args(self, task_def_arg, appspec_arg):
task_def_string = self._get_file_contents(task_def_arg)
register_task_def_kwargs = json.loads(task_def_string)
appspec_string = self._get_file_contents(appspec_arg)
appspec_obj = filehelpers.parse_appspec(appspec_string)
return register_task_def_kwargs, appspec_obj
def _register_task_def(self, task_def_kwargs, ecs_client):
response = ecs_client.register_task_definition(task_def_kwargs)
task_def_arn = response['taskDefinition']['taskDefinitionArn']
sys.stdout.write(self.MSG_TASK_DEF_REGISTERED.format(
arn=task_def_arn))
sys.stdout.flush()
return task_def_arn
def _validate_code_deploy_resources(self, client):
validator = CodeDeployValidator(client, self.resources)
validator.describe_cd_resources()
validator.validate_all()
self._cd_validator = validator
class CodeDeployer():
MSG_WAITING = ("Waiting for {deployment_id} to succeed "
"(will wait up to {wait} minutes)...\n")
def __init__(self, cd_client, appspec_dict):
self._client = cd_client
self._appspec_dict = appspec_dict
def create_deployment(self, app_name, deploy_grp_name):
request_obj = self._get_create_deploy_request(
app_name, deploy_grp_name)
try:
response = self._client.create_deployment(**request_obj)
except ClientError as e:
raise exceptions.ServiceClientError(
action='create deployment', error=e)
return response['deploymentId']
def _get_appspec_hash(self):
appspec_str = json.dumps(self._appspec_dict)
appspec_encoded = compat.ensure_bytes(appspec_str)
return hashlib.sha256(appspec_encoded).hexdigest()
def _get_create_deploy_request(self, app_name, deploy_grp_name):
return {
"applicationName": app_name,
"deploymentGroupName": deploy_grp_name,
"revision": {
"revisionType": "AppSpecContent",
"appSpecContent": {
"content": json.dumps(self._appspec_dict),
"sha256": self._get_appspec_hash()
}
}
}
def update_task_def_arn(self, new_arn):
"""
Inserts the ARN of the previously created ECS task definition
into the provided appspec.
Expected format of ECS appspec (YAML) is:
version: 0.0
resources:
- <service-name>:
type: AWS::ECS::Service
properties:
taskDefinition: <value> # replace this
loadBalancerInfo:
containerName: <value>
containerPort: <value>
"""
appspec_obj = self._appspec_dict
resources_key = filehelpers.find_required_key(
'codedeploy-appspec', appspec_obj, 'resources')
updated_resources = []
# 'resources' is a list of string:obj dictionaries
for resource in appspec_obj[resources_key]:
for name in resource:
# get content of resource
resource_content = resource[name]
# get resource properties
properties_key = filehelpers.find_required_key(
name, resource_content, 'properties')
properties_content = resource_content[properties_key]
# find task definition property
task_def_key = filehelpers.find_required_key(
properties_key, properties_content, 'taskDefinition')
# insert new task def ARN into resource
properties_content[task_def_key] = new_arn
updated_resources.append(resource)
appspec_obj[resources_key] = updated_resources
self._appspec_dict = appspec_obj
def wait_for_deploy_success(self, id, wait_min):
waiter = self._client.get_waiter("deployment_successful")
if wait_min is not None and wait_min > MAX_WAIT_MIN:
wait_min = MAX_WAIT_MIN
elif wait_min is None or wait_min < 30:
wait_min = 30
delay_sec = DEFAULT_DELAY_SEC
max_attempts = (wait_min * 60) / delay_sec
config = {
'Delay': delay_sec,
'MaxAttempts': max_attempts
}
self._show_deploy_wait_msg(id, wait_min)
waiter.wait(deploymentId=id, WaiterConfig=config)
def _show_deploy_wait_msg(self, id, wait_min):
sys.stdout.write(
self.MSG_WAITING.format(deployment_id=id,
wait=wait_min))
sys.stdout.flush()
class CodeDeployValidator():
def __init__(self, cd_client, resources):
self._client = cd_client
self._resource_names = resources
def describe_cd_resources(self):
try:
self.app_details = self._client.get_application(
applicationName=self._resource_names['app_name'])
except ClientError as e:
raise exceptions.ServiceClientError(
action='describe Code Deploy application', error=e)
try:
dgp = self._resource_names['deployment_group_name']
app = self._resource_names['app_name']
self.deployment_group_details = self._client.get_deployment_group(
applicationName=app, deploymentGroupName=dgp)
except ClientError as e:
raise exceptions.ServiceClientError(
action='describe Code Deploy deployment group', error=e)
def get_deployment_wait_time(self):
if (not hasattr(self, 'deployment_group_details') or
self.deployment_group_details is None):
return None
else:
dgp_info = self.deployment_group_details['deploymentGroupInfo']
blue_green_info = dgp_info['blueGreenDeploymentConfiguration']
deploy_ready_wait_min = \
blue_green_info['deploymentReadyOption']['waitTimeInMinutes']
terminate_key = 'terminateBlueInstancesOnDeploymentSuccess'
termination_wait_min = \
blue_green_info[terminate_key]['terminationWaitTimeInMinutes']
configured_wait = deploy_ready_wait_min + termination_wait_min
return configured_wait + TIMEOUT_BUFFER_MIN
def validate_all(self):
self.validate_application()
self.validate_deployment_group()
def validate_application(self):
app_name = self._resource_names['app_name']
if self.app_details['application']['computePlatform'] != 'ECS':
raise exceptions.InvalidPlatformError(
resource='Application', name=app_name)
def validate_deployment_group(self):
dgp = self._resource_names['deployment_group_name']
service = self._resource_names['service']
service_arn = self._resource_names['service_arn']
cluster = self._resource_names['cluster']
cluster_arn = self._resource_names['cluster_arn']
grp_info = self.deployment_group_details['deploymentGroupInfo']
compute_platform = grp_info['computePlatform']
if compute_platform != 'ECS':
raise exceptions.InvalidPlatformError(
resource='Deployment Group', name=dgp)
target_services = \
self.deployment_group_details['deploymentGroupInfo']['ecsServices']
# either ECS resource names or ARNs can be stored, so check both
for target in target_services:
target_serv = target['serviceName']
if target_serv != service and target_serv != service_arn:
raise exceptions.InvalidProperyError(
dg_name=dgp, resource='service', resource_name=service)
target_cluster = target['clusterName']
if target_cluster != cluster and target_cluster != cluster_arn:
raise exceptions.InvalidProperyError(
dg_name=dgp, resource='cluster', resource_name=cluster)
class ECSClient():
def __init__(self, session, parsed_args, parsed_globals, user_agent_extra):
self._args = parsed_args
self._custom_config = config.Config(user_agent_extra=user_agent_extra)
self._client = session.create_client(
'ecs',
region_name=parsed_globals.region,
endpoint_url=parsed_globals.endpoint_url,
verify=parsed_globals.verify_ssl,
config=self._custom_config)
def get_service_details(self):
cluster = self._args.cluster
if cluster is None or '':
cluster = 'default'
try:
service_response = self._client.describe_services(
cluster=cluster, services=[self._args.service])
except ClientError as e:
raise exceptions.ServiceClientError(
action='describe ECS service', error=e)
if len(service_response['services']) == 0:
raise exceptions.InvalidServiceError(
service=self._args.service, cluster=cluster)
service_details = service_response['services'][0]
cluster_name = \
filehelpers.get_cluster_name_from_arn(
service_details['clusterArn'])
return {
'service_arn': service_details['serviceArn'],
'service_name': service_details['serviceName'],
'cluster_arn': service_details['clusterArn'],
'cluster_name': cluster_name
}
def register_task_definition(self, kwargs):
try:
response = \
self._client.register_task_definition(**kwargs)
except ClientError as e:
raise exceptions.ServiceClientError(
action='register ECS task definition', error=e)
return response