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