1# Licensed to the Apache Software Foundation (ASF) under one or more
2# contributor license agreements.  See the NOTICE file distributed with
3# this work for additional information regarding copyright ownership.
4# The ASF licenses this file to You under the Apache License, Version 2.0
5# (the "License"); you may not use this file except in compliance with
6# the License.  You may obtain a copy of the License at
7#
8#     http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16try:
17    import simplejson as json
18except ImportError:
19    import json
20
21from libcloud.container.base import (ContainerDriver, Container,
22                                     ContainerCluster, ContainerImage)
23from libcloud.container.types import ContainerState
24from libcloud.container.utils.docker import RegistryClient
25from libcloud.common.aws import SignedAWSConnection, AWSJsonResponse
26
27__all__ = [
28    'ElasticContainerDriver'
29]
30
31
32ECS_VERSION = '2014-11-13'
33ECR_VERSION = '2015-09-21'
34ECS_HOST = 'ecs.%s.amazonaws.com'
35ECR_HOST = 'ecr.%s.amazonaws.com'
36ROOT = '/'
37ECS_TARGET_BASE = 'AmazonEC2ContainerServiceV%s' % \
38                  (ECS_VERSION.replace('-', ''))
39ECR_TARGET_BASE = 'AmazonEC2ContainerRegistry_V%s' % \
40                  (ECR_VERSION.replace('-', ''))
41
42
43class ECSJsonConnection(SignedAWSConnection):
44    version = ECS_VERSION
45    host = ECS_HOST
46    responseCls = AWSJsonResponse
47    service_name = 'ecs'
48
49
50class ECRJsonConnection(SignedAWSConnection):
51    version = ECR_VERSION
52    host = ECR_HOST
53    responseCls = AWSJsonResponse
54    service_name = 'ecr'
55
56
57class ElasticContainerDriver(ContainerDriver):
58    name = 'Amazon Elastic Container Service'
59    website = 'https://aws.amazon.com/ecs/details/'
60    ecr_repository_host = '%s.dkr.ecr.%s.amazonaws.com'
61    connectionCls = ECSJsonConnection
62    ecrConnectionClass = ECRJsonConnection
63    supports_clusters = False
64    status_map = {
65        'RUNNING': ContainerState.RUNNING
66    }
67
68    def __init__(self, access_id, secret, region):
69        super(ElasticContainerDriver, self).__init__(access_id, secret)
70        self.region = region
71        self.region_name = region
72        self.connection.host = ECS_HOST % (region)
73
74        # Setup another connection class for ECR
75        conn_kwargs = self._ex_connection_class_kwargs()
76        self.ecr_connection = self.ecrConnectionClass(
77            access_id, secret, **conn_kwargs)
78        self.ecr_connection.host = ECR_HOST % (region)
79        self.ecr_connection.driver = self
80        self.ecr_connection.connect()
81
82    def _ex_connection_class_kwargs(self):
83        return {'signature_version': '4'}
84
85    def list_images(self, ex_repository_name):
86        """
87        List the images in an ECR repository
88
89        :param  ex_repository_name: The name of the repository to check
90            defaults to the default repository.
91        :type   ex_repository_name: ``str``
92
93        :return: a list of images
94        :rtype: ``list`` of :class:`libcloud.container.base.ContainerImage`
95        """
96        request = {}
97        request['repositoryName'] = ex_repository_name
98        list_response = self.ecr_connection.request(
99            ROOT,
100            method='POST',
101            data=json.dumps(request),
102            headers=self._get_ecr_headers('ListImages')
103        ).object
104        repository_id = self.ex_get_repository_id(ex_repository_name)
105        host = self._get_ecr_host(repository_id)
106        return self._to_images(list_response['imageIds'],
107                               host,
108                               ex_repository_name)
109
110    def list_clusters(self):
111        """
112        Get a list of potential locations to deploy clusters into
113
114        :param  location: The location to search in
115        :type   location: :class:`libcloud.container.base.ClusterLocation`
116
117        :rtype: ``list`` of :class:`libcloud.container.base.ContainerCluster`
118        """
119        listdata = self.connection.request(
120            ROOT,
121            method='POST',
122            data=json.dumps({}),
123            headers=self._get_headers('ListClusters')
124        ).object
125        request = {'clusters': listdata['clusterArns']}
126        data = self.connection.request(
127            ROOT,
128            method='POST',
129            data=json.dumps(request),
130            headers=self._get_headers('DescribeClusters')
131        ).object
132        return self._to_clusters(data)
133
134    def create_cluster(self, name, location=None):
135        """
136        Create a container cluster
137
138        :param  name: The name of the cluster
139        :type   name: ``str``
140
141        :param  location: The location to create the cluster in
142        :type   location: :class:`libcloud.container.base.ClusterLocation`
143
144        :rtype: :class:`libcloud.container.base.ContainerCluster`
145        """
146        request = {'clusterName': name}
147        response = self.connection.request(
148            ROOT,
149            method='POST',
150            data=json.dumps(request),
151            headers=self._get_headers('CreateCluster')
152        ).object
153        return self._to_cluster(response['cluster'])
154
155    def destroy_cluster(self, cluster):
156        """
157        Delete a cluster
158
159        :return: ``True`` if the destroy was successful, otherwise ``False``.
160        :rtype: ``bool``
161        """
162        request = {'cluster': cluster.id}
163        data = self.connection.request(
164            ROOT,
165            method='POST',
166            data=json.dumps(request),
167            headers=self._get_headers('DeleteCluster')
168        ).object
169        return data['cluster']['status'] == 'INACTIVE'
170
171    def list_containers(self, image=None, cluster=None):
172        """
173        List the deployed container images
174
175        :param image: Filter to containers with a certain image
176        :type  image: :class:`libcloud.container.base.ContainerImage`
177
178        :param cluster: Filter to containers in a cluster
179        :type  cluster: :class:`libcloud.container.base.ContainerCluster`
180
181        :rtype: ``list`` of :class:`libcloud.container.base.Container`
182        """
183        request = {'cluster': 'default'}
184        if cluster is not None:
185            request['cluster'] = cluster.id
186        if image is not None:
187            request['family'] = image.name
188        list_response = self.connection.request(
189            ROOT,
190            method='POST',
191            data=json.dumps(request),
192            headers=self._get_headers('ListTasks')
193        ).object
194        if len(list_response['taskArns']) == 0:
195            return []
196        containers = self.ex_list_containers_for_task(
197            list_response['taskArns'])
198        return containers
199
200    def deploy_container(self, name, image, cluster=None,
201                         parameters=None, start=True, ex_cpu=10, ex_memory=500,
202                         ex_container_port=None, ex_host_port=None):
203        """
204        Creates a task definition from a container image that can be run
205        in a cluster.
206
207        :param name: The name of the new container
208        :type  name: ``str``
209
210        :param image: The container image to deploy
211        :type  image: :class:`libcloud.container.base.ContainerImage`
212
213        :param cluster: The cluster to deploy to, None is default
214        :type  cluster: :class:`libcloud.container.base.ContainerCluster`
215
216        :param parameters: Container Image parameters
217        :type  parameters: ``str``
218
219        :param start: Start the container on deployment
220        :type  start: ``bool``
221
222        :rtype: :class:`libcloud.container.base.Container`
223        """
224        data = {}
225        if ex_container_port is None and ex_host_port is None:
226            port_maps = []
227        else:
228            port_maps = [
229                {
230                    "containerPort": ex_container_port,
231                    "hostPort": ex_host_port
232                }
233            ]
234        data['containerDefinitions'] = [
235            {
236                "mountPoints": [],
237                "name": name,
238                "image": image.name,
239                "cpu": ex_cpu,
240                "environment": [],
241                "memory": ex_memory,
242                "portMappings": port_maps,
243                "essential": True,
244                "volumesFrom": []
245            }
246        ]
247        data['family'] = name
248        response = self.connection.request(
249            ROOT,
250            method='POST',
251            data=json.dumps(data),
252            headers=self._get_headers('RegisterTaskDefinition')
253        ).object
254        if start:
255            return self.ex_start_task(
256                response['taskDefinition']['taskDefinitionArn'])[0]
257        else:
258            return Container(
259                id=None,
260                name=name,
261                image=image,
262                state=ContainerState.RUNNING,
263                ip_addresses=[],
264                extra={
265                    'taskDefinitionArn':
266                        response['taskDefinition']['taskDefinitionArn']
267                },
268                driver=self.connection.driver
269            )
270
271    def get_container(self, id):
272        """
273        Get a container by ID
274
275        :param id: The ID of the container to get
276        :type  id: ``str``
277
278        :rtype: :class:`libcloud.container.base.Container`
279        """
280        containers = self.ex_list_containers_for_task([id])
281        return containers[0]
282
283    def start_container(self, container, count=1):
284        """
285        Start a deployed task
286
287        :param container: The container to start
288        :type  container: :class:`libcloud.container.base.Container`
289
290        :param count: Number of containers to start
291        :type  count: ``int``
292
293        :rtype: :class:`libcloud.container.base.Container`
294        """
295        return self.ex_start_task(container.extra['taskDefinitionArn'], count)
296
297    def stop_container(self, container):
298        """
299        Stop a deployed container
300
301        :param container: The container to stop
302        :type  container: :class:`libcloud.container.base.Container`
303
304        :rtype: :class:`libcloud.container.base.Container`
305        """
306        request = {'task': container.extra['taskArn']}
307        response = self.connection.request(
308            ROOT,
309            method='POST',
310            data=json.dumps(request),
311            headers=self._get_headers('StopTask')
312        ).object
313        containers = []
314        containers.extend(self._to_containers(
315            response['task'],
316            container.extra['taskDefinitionArn']))
317        return containers
318
319    def restart_container(self, container):
320        """
321        Restart a deployed container
322
323        :param container: The container to restart
324        :type  container: :class:`libcloud.container.base.Container`
325
326        :rtype: :class:`libcloud.container.base.Container`
327        """
328        self.stop_container(container)
329        return self.start_container(container)
330
331    def destroy_container(self, container):
332        """
333        Destroy a deployed container
334
335        :param container: The container to destroy
336        :type  container: :class:`libcloud.container.base.Container`
337
338        :rtype: :class:`libcloud.container.base.Container`
339        """
340        return self.stop_container(container)
341
342    def ex_start_task(self, task_arn, count=1):
343        """
344        Run a task definition and get the containers
345
346        :param task_arn: The task ARN to Run
347        :type  task_arn: ``str``
348
349        :param count: The number of containers to start
350        :type  count: ``int``
351
352        :rtype: ``list`` of :class:`libcloud.container.base.Container`
353        """
354        request = None
355        request = {'count': count,
356                   'taskDefinition': task_arn}
357        response = self.connection.request(
358            ROOT,
359            method='POST',
360            data=json.dumps(request),
361            headers=self._get_headers('RunTask')
362        ).object
363        containers = []
364        for task in response['tasks']:
365            containers.extend(self._to_containers(task, task_arn))
366        return containers
367
368    def ex_list_containers_for_task(self, task_arns):
369        """
370        Get a list of containers by ID collection (ARN)
371
372        :param task_arns: The list of ARNs
373        :type  task_arns: ``list`` of ``str``
374
375        :rtype: ``list`` of :class:`libcloud.container.base.Container`
376        """
377        describe_request = {'tasks': task_arns}
378        descripe_response = self.connection.request(
379            ROOT,
380            method='POST',
381            data=json.dumps(describe_request),
382            headers=self._get_headers('DescribeTasks')
383        ).object
384        containers = []
385        for task in descripe_response['tasks']:
386            containers.extend(self._to_containers(
387                task, task['taskDefinitionArn']))
388        return containers
389
390    def ex_create_service(self, name, cluster,
391                          task_definition, desired_count=1):
392        """
393        Runs and maintains a desired number of tasks from a specified
394        task definition. If the number of tasks running in a service
395        drops below desired_count, Amazon ECS spawns another
396        instantiation of the task in the specified cluster.
397
398        :param  name: the name of the service
399        :type   name: ``str``
400
401        :param  cluster: The cluster to run the service on
402        :type   cluster: :class:`libcloud.container.base.ContainerCluster`
403
404        :param  task_definition: The task definition name or ARN for the
405            service
406        :type   task_definition: ``str``
407
408        :param  desired_count: The desired number of tasks to be running
409            at any one time
410        :type   desired_count: ``int``
411
412        :rtype: ``object`` The service object
413        """
414        request = {
415            'serviceName': name,
416            'taskDefinition': task_definition,
417            'desiredCount': desired_count,
418            'cluster': cluster.id}
419        response = self.connection.request(
420            ROOT,
421            method='POST',
422            data=json.dumps(request),
423            headers=self._get_headers('CreateService')
424        ).object
425        return response['service']
426
427    def ex_list_service_arns(self, cluster=None):
428        """
429        List the services
430
431        :param cluster: The cluster hosting the services
432        :type  cluster: :class:`libcloud.container.base.ContainerCluster`
433
434        :rtype: ``list`` of ``str``
435        """
436        request = {}
437        if cluster is not None:
438            request['cluster'] = cluster.id
439        response = self.connection.request(
440            ROOT,
441            method='POST',
442            data=json.dumps(request),
443            headers=self._get_headers('ListServices')
444        ).object
445        return response['serviceArns']
446
447    def ex_describe_service(self, service_arn):
448        """
449        Get the details of a service
450
451        :param  cluster: The hosting cluster
452        :type   cluster: :class:`libcloud.container.base.ContainerCluster`
453
454        :param  service_arn: The service ARN to describe
455        :type   service_arn: ``str``
456
457        :return: The service object
458        :rtype: ``object``
459        """
460        request = {'services': [service_arn]}
461        response = self.connection.request(
462            ROOT,
463            method='POST',
464            data=json.dumps(request),
465            headers=self._get_headers('DescribeServices')
466        ).object
467        return response['services'][0]
468
469    def ex_destroy_service(self, service_arn):
470        """
471        Deletes a service
472
473        :param  cluster: The target cluster
474        :type   cluster: :class:`libcloud.container.base.ContainerCluster`
475
476        :param  service_arn: The service ARN to destroy
477        :type   service_arn: ``str``
478        """
479        request = {
480            'service': service_arn}
481        response = self.connection.request(
482            ROOT,
483            method='POST',
484            data=json.dumps(request),
485            headers=self._get_headers('DeleteService')
486        ).object
487        return response['service']
488
489    def ex_get_registry_client(self, repository_name):
490        """
491        Get a client for an ECR repository
492
493        :param  repository_name: The unique name of the repository
494        :type   repository_name: ``str``
495
496        :return: a docker registry API client
497        :rtype: :class:`libcloud.container.utils.docker.RegistryClient`
498        """
499        repository_id = self.ex_get_repository_id(repository_name)
500        token = self.ex_get_repository_token(repository_id)
501        host = self._get_ecr_host(repository_id)
502        return RegistryClient(
503            host=host,
504            username='AWS',
505            password=token
506        )
507
508    def ex_get_repository_token(self, repository_id):
509        """
510        Get the authorization token (12 hour expiry) for a repository
511
512        :param  repository_id: The ID of the repository
513        :type   repository_id: ``str``
514
515        :return: A token for login
516        :rtype: ``str``
517        """
518        request = {'RegistryIds': [repository_id]}
519        response = self.ecr_connection.request(
520            ROOT,
521            method='POST',
522            data=json.dumps(request),
523            headers=self._get_ecr_headers('GetAuthorizationToken')
524        ).object
525        return response['authorizationData'][0]['authorizationToken']
526
527    def ex_get_repository_id(self, repository_name):
528        """
529        Get the ID of a repository
530
531        :param  repository_name: The unique name of the repository
532        :type   repository_name: ``str``
533
534        :return: The repository ID
535        :rtype: ``str``
536        """
537        request = {'repositoryNames': [repository_name]}
538        list_response = self.ecr_connection.request(
539            ROOT,
540            method='POST',
541            data=json.dumps(request),
542            headers=self._get_ecr_headers('DescribeRepositories')
543        ).object
544        repository_id = list_response['repositories'][0]['registryId']
545        return repository_id
546
547    def _get_ecr_host(self, repository_id):
548        return self.ecr_repository_host % (
549            repository_id,
550            self.region)
551
552    def _get_headers(self, action):
553        """
554        Get the default headers for a request to the ECS API
555        """
556        return {'x-amz-target': '%s.%s' %
557                (ECS_TARGET_BASE, action),
558                'Content-Type': 'application/x-amz-json-1.1'
559                }
560
561    def _get_ecr_headers(self, action):
562        """
563        Get the default headers for a request to the ECR API
564        """
565        return {'x-amz-target': '%s.%s' %
566                (ECR_TARGET_BASE, action),
567                'Content-Type': 'application/x-amz-json-1.1'
568                }
569
570    def _to_clusters(self, data):
571        clusters = []
572        for cluster in data['clusters']:
573            clusters.append(self._to_cluster(cluster))
574        return clusters
575
576    def _to_cluster(self, data):
577        return ContainerCluster(
578            id=data['clusterArn'],
579            name=data['clusterName'],
580            driver=self.connection.driver
581        )
582
583    def _to_containers(self, data, task_definition_arn):
584        clusters = []
585        for cluster in data['containers']:
586            clusters.append(self._to_container(cluster, task_definition_arn))
587        return clusters
588
589    def _to_container(self, data, task_definition_arn):
590        return Container(
591            id=data['containerArn'],
592            name=data['name'],
593            image=ContainerImage(
594                id=None,
595                name=data['name'],
596                path=None,
597                version=None,
598                driver=self.connection.driver
599            ),
600            ip_addresses=None,
601            state=self.status_map.get(data['lastStatus'], None),
602            extra={
603                'taskArn': data['taskArn'],
604                'taskDefinitionArn': task_definition_arn
605            },
606            driver=self.connection.driver
607        )
608
609    def _to_images(self, data, host, repository_name):
610        images = []
611        for image in data:
612            images.append(self._to_image(image, host, repository_name))
613        return images
614
615    def _to_image(self, data, host, repository_name):
616        path = '%s/%s:%s' % (
617            host,
618            repository_name,
619            data['imageTag']
620        )
621        return ContainerImage(
622            id=None,
623            name=path,
624            path=path,
625            version=data['imageTag'],
626            driver=self.connection.driver
627        )
628