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