コード例 #1
0
ファイル: Service.py プロジェクト: chrisLeeTW/deployfish
class Service(object):
    """
    An object representing an ECS service.
    """

    @classmethod
    def url(cluster, service):
        """
        Return the AWS Web Console URL for service ``service`` in ECS cluster ``cluster``
        in region ``region`` as Markdown.  Suitable for inserting into a Slack message.

        :param region: the name of a valid AWS region
        :type region: string

        :param cluster: the name of an ECS cluster
        :type cluster: string

        :param service: the name of an ECS service in cluster ``cluster``
        :type service: string

        :rtype: string
        """
        region = os.environ.get('AWS_DEFAULT_REGION', 'us-west-2')
        return u"<https://{}.console.aws.amazon.com/ecs/home?region={}#/clusters/{}/services/{}/tasks|{}>".format(
            region,
            region,
            cluster,
            service,
            service
        )

    def __init__(self, service_name, config=None):
        self.yml = config.get_service(service_name)
        self.ecs = get_boto3_session().client('ecs')
        self.__aws_service = None

        self.asg = None
        self.scaling = None
        self.serviceDiscovery = None
        self.searched_hosts = False
        self.is_running = False
        self.instances = []
        self.hosts = None
        self.host_ips = None
        self._serviceName = None
        self._clusterName = None
        self._desired_count = 0
        self._minimumHealthyPercent = None
        self._maximumPercent = None
        self._launchType = 'EC2'
        self.__service_discovery = []
        self.__defaults()
        self.from_yaml(self.yml)
        self.from_aws()

        # self.sshobj = SSHConfig(self, yml, config).get_ssh()
        # print(type(self.sshobj.provider))

    def __defaults(self):
        self._roleArn = None
        self.__load_balancer = None
        self.__vpc_configuration = {}
        self.__placement_constraints = []
        self.__placement_strategy = []
        self.__schedulingStrategy = "REPLICA"
        self.__capacity_provider_strategy = []

    def __get_service(self):
        """
        If a service named ``self.serviceName`` in a cluster named
        ``self.clusterName`` exists, return its data, else return an
        empty dict.

        :rtype: dict
        """
        response = self.ecs.describe_services(
            cluster=self._clusterName,
            services=[self._serviceName]
        )
        if response['services'] and response['services'][0]['status'] != 'INACTIVE':
            return response['services'][0]
        else:
            return {}

    def __getattr__(self, attr):
        """
        We have this __getattr__ here to access some attributes on the dict that AWS
        returns to us via the ``describe_services()`` call.
        """
        try:
            return self.__getattribute__(attr)
        except AttributeError:
            if attr in [
                'deployments',
                'taskDefinition',
                'clusterArn',
                'desiredCount',
                'runningCount',
                'pendingCount',
                'networkConfiguration',
                'executionRoleArn'
            ]:
                if self.__aws_service:
                    return self.__aws_service[attr]
                return None
            else:
                raise AttributeError

    def exists(self):
        """
        Return ``True`` if our service exists in the specified cluster in AWS,
        ``False`` otherwise.

        :rtype: boolean
        """
        if self.__aws_service:
            return True
        return False

    @property
    def count(self):
        """
        For services yet to be created, return what we want the task count
        to be when we create the service.

        For services already existing in AWS, return the actual current number
        of running tasks.

        :rtype: int
        """
        if self.__aws_service:
            self._count = self.__aws_service['runningCount']
        return self._count

    @count.setter
    def count(self, count):
        """
        Set the count of tasks this service should run.  Setting this
        has no effect if the service already exists.  Use ``Service.scale()``
        to affect this instead.

        :param count: number of tasks this service should run
        :type count: int
        """
        self._count = count

    @property
    def maximumPercent(self):
        """
        If maximumPercent is defined in deployfish.yml for our service
        return that value.

        If it is not defined in deployfish.yml, but it is defined in AWS, return
        the AWS maximumPercent value.

        Else, return 200.

        :rtype: int
        """
        if not self._maximumPercent:
            if self.__aws_service:
                self._maximumPercent = self.__aws_service['deploymentConfiguration']['maximumPercent']
            else:
                # Give a reasonable default if it was not defined in deployfish.yml
                self._maximumPercent = 200
        return self._maximumPercent

    @maximumPercent.setter
    def maximumPercent(self, maximumPercent):
        """
        Set the maximum percent of tasks this service is allowed to be in the
        RUNNING or PENDING state during a deployment.  Setting this has no
        effect if the service already exists.

        :param maximumPercent: Set the maximum percent of tasks this service is allowed to run
        :type count: int
        """
        self._maximumPercent = maximumPercent

    @property
    def minimumHealthyPercent(self):
        """
        If minimumHealthyPercent is defined in deployfish.yml for our service,
        return that value.

        If it is not defined in deployfish.yml, but it is defined in AWS, return
        the AWS minimumHealthyPercent value.

        Else, return 0.

        :rtype: int
        """
        if not self._minimumHealthyPercent:
            if self.__aws_service:
                self._minimumHealthyPercent = self.__aws_service['deploymentConfiguration']['minimumHealthyPercent']
            else:
                # Give a reasonable default if it was not defined in deployfish.yml
                self._minimumHealthyPercent = 0
        return self._minimumHealthyPercent

    @minimumHealthyPercent.setter
    def minimumHealthyPercent(self, minimumHealthyPercent):
        """
        Set the minimum percent of tasks this service must maintain in the
        RUNNING or PENDING state during a deployment.  Setting this has no
        effect if the service already exists.

        :param maximumPercent: Set the minimum percent of tasks this service must maintain
        :type count: int
        """
        self._minimumHealthyPercent = minimumHealthyPercent

    @property
    def serviceName(self):
        """
        Return the name of our service.

        :rtype: string
        """
        if self.__aws_service:
            self._serviceName = self.__aws_service['serviceName']
        return self._serviceName

    @serviceName.setter
    def serviceName(self, serviceName):
        self._serviceName = serviceName

    @property
    def launchType(self):
        """
        Return the launch type of our service.

        :rtype: string
        """
        if self.__aws_service:
            self._launchType = self.__aws_service['launchType']
        return self._launchType

    @launchType.setter
    def launchType(self, launchType):
        self._launchType = launchType

    @property
    def clusterName(self):
        """
        Return the name of the cluster our service is or will be running in.

        :rtype: string
        """
        if self.__aws_service:
            self._clusterName = os.path.basename(self.__aws_service['clusterArn'])
        return self._clusterName

    @clusterName.setter
    def clusterName(self, clusterName):
        self._clusterName = clusterName

    @property
    def roleArn(self):
        if self.__aws_service:
            self._roleArn = self.__aws_service['roleArn']
        return self._roleArn

    @roleArn.setter
    def roleArn(self, roleArn):
        self._roleArn = roleArn

    @property
    def client_token(self):
        token = 'token-{}-{}'.format(self.serviceName, self.clusterName)
        if len(token) > 36:
            token = token[0:35]
        return token

    @property
    def active_deployment(self):
        for deployment in self.deployments:
            if deployment['taskDefinition'] == self.taskDefinition:
                break
        return deployment

    def kill_task(self, task_arn):
        """
        Kill off one of our tasks.  Do nothing if the task doesn't belong to
        this service.

        :param task_arn: the ARN of an existing task in our service
        :type task_arn: string
        """
        if task_arn in self.task_arns:
            self.ecs.stop_task(
                cluster=self.clusterName,
                task=task_arn
            )

    def restart(self, hard=False):
        """
        Kill off tasks in the our service one by one, letting them be
        replaced by tasks from the same task definition.  This effectively
        "restarts" the tasks.

        :param hard: if True, kill off all running tasks instantly
        :type hard: boolean
        """
        for task_arn in self.task_arns:
            self.kill_task(task_arn)
            if not hard:
                self.wait_until_stable()
        if hard:
            self.wait_until_stable()

    @property
    def task_arns(self):
        """
        Returns a list of taskArns for all tasks currently running in the service.

        :rtype: list ot strings
        """
        response = self.ecs.list_tasks(
            cluster=self.clusterName,
            serviceName=self.serviceName
        )
        return response['taskArns']

    @property
    def load_balancer(self):
        """
        Returns the load balancer, either elb or alb, if it exists.

        :return: dict
        """
        if self.__aws_service:
            if self.__aws_service['loadBalancers']:
                if 'loadBalancerName' in self.__aws_service['loadBalancers'][0]:
                    self.__load_balancer = {
                        'type': 'elb',
                        'load_balancer_name': self.__aws_service['loadBalancers'][0]['loadBalancerName'],
                        'container_name': self.__aws_service['loadBalancers'][0]['containerName'],
                        'container_port': self.__aws_service['loadBalancers'][0]['containerPort']
                    }
                else:
                    self.__load_balancer = []
                    for target_group in self.__aws_service['loadBalancers']:
                        self.__load_balancer.append({
                            'type': 'alb',
                            'target_group_arn': target_group['targetGroupArn'],
                            'container_name': target_group['containerName'],
                            'container_port': target_group['containerPort']
                        })
        return self.__load_balancer

    def set_elb(self, load_balancer_name, container_name, container_port):
        self.__load_balancer = {
            'type': 'elb',
            'load_balancer_name': load_balancer_name,
            'container_name': container_name,
            'container_port': container_port
        }

    def set_alb(self, target_groups):
        self.__load_balancer = []
        for item in target_groups:
            self.__load_balancer.append({
                'type': 'alb',
                'target_group_arn': item['target_group_arn'],
                'container_name': item['container_name'],
                'container_port': item['container_port']
            })

    @property
    def vpc_configuration(self):
        if self.__aws_service and 'networkConfiguration' in self.__aws_service and not self.__vpc_configuration:
            self.__vpc_configuration = self.__aws_service['networkConfiguration']['awsvpcConfiguration']
        return self.__vpc_configuration

    def set_vpc_configuration(self, subnets, security_groups, public_ip):
        self.__vpc_configuration = {
            'subnets': subnets,
            'securityGroups': security_groups,
            'assignPublicIp': public_ip
        }

    @property
    def service_discovery(self):
        if self.__aws_service:
            if self.__aws_service['serviceRegistries']:
                if 'registryArn' in self.__aws_service['serviceRegistries'][0]:
                    self.__service_discovery = self.__aws_service['serviceRegistries']
        return self.__service_discovery

    @service_discovery.setter
    def service_discovery(self, arn):
        self.__service_discovery = [{'registryArn': arn}]

    @property
    def capacity_provider_strategy(self):
        """
        Returns the load balancer, either elb or alb, if it exists.

        :return: dict
        """
        if self.__aws_service:
            if 'capacityProviderStrategy' in self.__aws_service:
                self.__capacity_provider_strategy = []
                for provider in self.__aws_service['capacityProviderStrategy']:
                    p = {
                        'provider': provider['capacityProvider'],
                        'weight': provider['weight']
                    }
                    if 'base' in provider:
                        p['base'] = provider['base']
                    self.__capacity_provider_strategy.append(p)
        return self.__capacity_provider_strategy

    def version(self):
        if self.active_task_definition:
            if self.load_balancer:
                if isinstance(self.load_balancer, dict):
                    # This is an ELB
                    item = self.load_balancer
                else:
                    # This is a list of target groups
                    item = self.load_balancer[0]
                for c in self.active_task_definition.containers:
                    if c.name == item['container_name']:
                        return c.image.split(":")[1]
            else:
                # Just give the first container's version?
                return self.active_task_definition.containers[0].image.split(":")[1]
        return None

    @property
    def placementConstraints(self):
        if self.__aws_service:
            if self.__aws_service['placementConstraints']:
                self.__placement_constraints = self.__aws_service['placementConstraints']
        return self.__placement_constraints

    @placementConstraints.setter
    def placementConstraints(self, placementConstraints):
        if isinstance(placementConstraints, list):
            self.__placement_constraints = []
            for placement in placementConstraints:
                configDict = {'type': placement['type']}
                if 'expression' in placement:
                    configDict['expression'] = placement['expression']
                self.__placement_constraints.append(configDict)

    @property
    def placementStrategy(self):
        if self.__aws_service:
            if self.__aws_service['placementStrategy']:
                self.__placement_strategy = self.__aws_service['placementStrategy']
        return self.__placement_strategy

    @placementStrategy.setter
    def placementStrategy(self, placementStrategy):
        if isinstance(placementStrategy, list):
            self.__placement_strategy = []
            for placement in placementStrategy:
                configDict = {'type': placement['type']}
                if 'field' in placement:
                    configDict['field'] = placement['field']
                self.__placement_strategy.append(configDict)

    @property
    def schedulingStrategy(self):
        if self.__aws_service:
            if self.__aws_service['schedulingStrategy']:
                self.__schedulingStrategy = self.__aws_service['schedulingStrategy']
        return self.__schedulingStrategy

    @schedulingStrategy.setter
    def schedulingStrategy(self, schedulingStrategy):
        self.__schedulingStrategy = schedulingStrategy

    def _render(self, task_definition_id):
        """
        Generate the dict we will pass to boto3's `create_service()`.

        :rtype: dict
        """
        r = {}
        r['cluster'] = self.clusterName
        r['serviceName'] = self.serviceName
        if not self.capacity_provider_strategy:
            # capacity_provider_strategy and launch_type are mutually exclusive
            r['launchType'] = self.launchType
        if self.load_balancer:
            if self.launchType != 'FARGATE' and self.roleArn:
                r['role'] = self.roleArn
            r['loadBalancers'] = []
            if isinstance(self.load_balancer, dict):
                # An ELB
                r['loadBalancers'].append({
                    'loadBalancerName': self.load_balancer['load_balancer_name'],
                    'containerName': self.load_balancer['container_name'],
                    'containerPort': self.load_balancer['container_port'],
                })
            else:
                # a list of target groups
                for target_group in self.load_balancer:
                    r['loadBalancers'].append({
                        'targetGroupArn': target_group['target_group_arn'],
                        'containerName': target_group['container_name'],
                        'containerPort': target_group['container_port'],
                    })
        if self.vpc_configuration:
            r['networkConfiguration'] = {
                'awsvpcConfiguration': self.vpc_configuration
            }
        r['taskDefinition'] = task_definition_id
        if self.schedulingStrategy != "DAEMON":
            r['desiredCount'] = self.count
        r['clientToken'] = self.client_token
        if self.__service_discovery:
            r['serviceRegistries'] = self.__service_discovery
        r['deploymentConfiguration'] = {
            'maximumPercent': self.maximumPercent,
            'minimumHealthyPercent': self.minimumHealthyPercent
        }
        if len(self.placementConstraints) > 0:
            r['placementConstraints'] = self.placementConstraints
        if len(self.placementStrategy) > 0:
            r['placementStrategy'] = self.placementStrategy
        if self.schedulingStrategy:
            r['schedulingStrategy'] = self.schedulingStrategy
        if self.capacity_provider_strategy:
            cps = []
            for p in self.capacity_provider_strategy:
                ps = {
                    'capacityProvider': p['provider'],
                    'weight': p['weight']
                }
                if 'base' in p:
                    ps['base'] = p['base']
                cps.append(ps)
            r['capacityProviderStrategy'] = cps
        return r

    def from_yaml(self, yml):
        """
        Load our service information from the parsed yaml.  ``yml`` should be
        a service level entry from the ``deployfish.yml`` file.

        :param yml: a service level entry from the ``deployfish.yml`` file
        :type yml: dict
        """
        self.serviceName = yml['name']
        self.clusterName = yml['cluster']
        if 'launch_type' in yml:
            self.launchType = yml['launch_type']
        self.environment = yml.get('environment', 'undefined')
        self.family = yml['family']
        # backwards compatibility for deployfish.yml < 0.16.0
        if 'maximum_percent' in yml:
            self.maximumPercent = yml['maximum_percent']
            self.minimumHealthyPercent = yml['minimum_healthy_percent']
        self.asg = ASG(yml=yml)
        if 'application_scaling' in yml:
            # Application Autoscaling
            self.scaling = ApplicationAutoscaling(yml['name'], yml['cluster'], yml=yml['application_scaling'])
        if 'load_balancer' in yml:
            if 'service_role_arn' in yml:
                # backwards compatibility for deployfish.yml < 0.3.6
                self.roleArn = yml['service_role_arn']
            elif 'load_balancer' in yml and 'service_role_arn' in yml['load_balancer']:
                self.roleArn = yml['load_balancer']['service_role_arn']
            if 'target_groups' in yml['load_balancer']:
                # If we want the service to register itself with multiple target groups,
                # the "load_balancer" section will have a list entry named "target_groups".
                # Each item in the target_group_list will be a dict with keys "target_group_arn",
                # "container_name" and "container_port"
                self.set_alb(yml['load_balancer']['target_groups'])
            else:
                # We either have just one target group, or we're using an ELB
                if 'load_balancer_name' in yml['load_balancer']:
                    # ELB
                    self.set_elb(
                        yml['load_balancer']['load_balancer_name'],
                        yml['load_balancer']['container_name'],
                        yml['load_balancer']['container_port'],
                    )
                elif 'target_group_arn' in yml['load_balancer']:
                    # target group
                    self.set_alb([yml['load_balancer']])
        if 'vpc_configuration' in yml:
            self.set_vpc_configuration(
                yml['vpc_configuration']['subnets'],
                yml['vpc_configuration']['security_groups'],
                yml['vpc_configuration']['public_ip'],
            )
        if 'network_mode' in yml:
            if yml['network_mode'] == 'awsvpc' and 'service_discovery' in yml:
                self.serviceDiscovery = ServiceDiscovery(None, yml=yml['service_discovery'])
            elif 'service_discovery' in yml:
                print("Ignoring service discovery config since network mode is not awsvpc")
        if 'placement_constraints' in yml:
            self.placementConstraints = yml['placement_constraints']
        if 'placement_strategy' in yml:
            self.placementStrategy = yml['placement_strategy']
        if 'scheduling_strategy' in yml and yml['scheduling_strategy'] == 'DAEMON':
            self.schedulingStrategy = yml['scheduling_strategy']
            self._count = 'automatically'
            self.maximumPercent = 100
        else:
            self._count = yml['count']
            self._desired_count = self._count
        if 'capacity_provider_strategy' in yml:
            self.__capacity_provider_strategy = yml['capacity_provider_strategy']
        self.desired_task_definition = TaskDefinition(yml=yml)
        deployfish_environment = {
            "DEPLOYFISH_SERVICE_NAME": yml['name'],
            "DEPLOYFISH_ENVIRONMENT": yml.get('environment', 'undefined'),
            "DEPLOYFISH_CLUSTER_NAME": yml['cluster']
        }
        self.desired_task_definition.inject_environment(deployfish_environment)
        self.tasks = {}
        if 'tasks' in yml:
            for task in yml['tasks']:
                t = HelperTask(yml['cluster'], yml=task)
                self.tasks[t.family] = t
        parameters = []
        if 'config' in yml:
            parameters = yml['config']
        self.parameter_store = ParameterStore(self._serviceName, self._clusterName, yml=parameters)

    def from_aws(self):
        """
        Update our service definition, task definition and tasks from the live
        versions in AWS.
        """
        self.__aws_service = self.__get_service()
        if not self.scaling:
            # This only gets executed if we don't have an "application_scaling"
            # section in our service YAML definition.
            #
            # But we're looking here for an autoscaling setup that we previously
            # had created but which we no longer want
            self.scaling = ApplicationAutoscaling(self.serviceName, self.clusterName)
            if not self.scaling.exists():
                self.scaling = None
        if self.__aws_service:
            self.active_task_definition = TaskDefinition(self.taskDefinition)
            # If we have helper tasks, update them from AWS now
            helpers = self.active_task_definition.get_helper_tasks()
            if helpers:
                for t in self.tasks.values():
                    t.from_aws(helpers[t.family])

            if self.__aws_service['serviceRegistries']:
                self.serviceDiscovery = ServiceDiscovery(self.service_discovery[0]['registryArn'])
            else:
                self.serviceDiscovery = None
        else:
            self.active_task_definition = None

    def __create_tasks_and_task_definition(self):
        """
        Create the new task definition for our service.

        If we have any helper tasks associated with our service, create
        them first, then and pass their information into the service
        task definition.
        """
        family_revisions = []
        if self.desired_task_definition.executionRoleArn:
            self.parameter_store.populate()
        for task in self.tasks.values():
            if self.desired_task_definition.executionRoleArn:
                task.desired_task_definition.set_parameter_store(self.parameter_store)
            task.create()
            family_revisions.append(task.family_revision)
        if self.desired_task_definition.executionRoleArn:
            self.desired_task_definition.set_parameter_store(self.parameter_store)
        self.desired_task_definition.update_task_labels(family_revisions)
        self.desired_task_definition.create()

    def create(self):
        """
        Create the service in AWS.  If necessary, setup Application Scaling afterwards.
        """
        if self.serviceDiscovery is not None:
            if not self.serviceDiscovery.exists():
                self.service_discovery = self.serviceDiscovery.create()
            else:
                print("Service Discovery already exists with this name")
        self.__create_tasks_and_task_definition()
        kwargs = self._render(self.desired_task_definition.arn)
        self.ecs.create_service(**kwargs)
        if self.scaling:
            self.scaling.create()
        self.__defaults()
        self.from_aws()

    def update(self):
        """
        Update the service and Application Scaling setup (if any).

        If we currently don't have Application Scaling enabled, but we want it now,
        set it up appropriately.

        If we currently do have Application Scaling enabled, but it's setup differently
        than we want it, update it appropriately.

        If we currently do have Application Scaling enabled, but we no longer want it,
        remove Application Scaling.
        """
        self.update_service()
        self.update_scaling()

    def _render_update(self, task_definition_arn):
        """
        Generate the dict we will pass to boto3's `update_service()`.

        :rtype: dict
        """
        r = {}
        r['cluster'] = self.clusterName
        r['service'] = self.serviceName
        r['taskDefinition'] = task_definition_arn
        r['deploymentConfiguration'] = {
            'maximumPercent': self.maximumPercent,
            'minimumHealthyPercent': self.minimumHealthyPercent
        }
        if self.vpc_configuration:
            r['networkConfiguration'] = {'awsvpcConfiguration': self.vpc_configuration}

        # The boto3 docs say we can pass capacityProviderStrategy in boto3>=1.10.41, but
        # when I actually try to use it with 1.10.41, I get:
        #
        #  Unknown parameter in input: "capacityProviderStrategy", must be one of: cluster, service, desiredCount,
        #    taskDefinition, deploymentConfiguration, networkConfiguration, platformVersion, forceNewDeployment,
        #    healthCheckGracePeriodSeconds
        #
        # Commenting this out until it is actually allowed.
        # -- CPM 2019-12-18

#         if self.capacity_provider_strategy:
#            cps = []
#            for p in self.capacity_provider_strategy:
#                ps = {
#                    'capacityProvider': p['provider'],
#                    'weight': p['weight']
#                }
#                if 'base' in p:
#                    ps['base'] = p['base']
#                cps.append(ps)
#            r['capacityProviderStrategy'] = cps
        return r

    def update_service(self):
        """
        Update the taskDefinition and deploymentConfiguration on the service.
        """
        self.__create_tasks_and_task_definition()
        self.ecs.update_service(
            **self._render_update(self.desired_task_definition.arn)
        )
        self.__defaults()
        self.from_aws()

    def update_scaling(self):
        if self.scaling:
            if self.scaling.should_exist():
                if not self.scaling.exists():
                    self.scaling.create()
                else:
                    self.scaling.update()
            else:
                if self.scaling.exists():
                    self.scaling.delete()

    def scale(self, count):
        """
        Update ``desiredCount`` on our service to ``count``.

        :param count: set # of containers on our service to this
        :type count: integer
        """
        # TBD: if our schedulingStrategy is DAEMON, this should do nothing
        #      except print a message
        self.ecs.update_service(
            cluster=self.clusterName,
            service=self.serviceName,
            desiredCount=count
        )
        self._desired_count = count
        self.__defaults()
        self.from_aws()

    def delete(self):
        """
        Delete the service from AWS, as well as any related Application Scaling
        objects or service discovery objects.
        """

        # We need to delete any autoscaling stuff before deleting the service
        # because we want to delete the cloudwatch alarms associated with our
        # scaling policies.  If we delete the service first, ECS will happily
        # auto-delete the scaling target and scaling polices, but leave the
        # cloudwatch alarms hanging.  Then when we go to remove the scaling,
        # we won't know how to lookup the alarms
        if self.scaling and self.scaling.exists():
            self.scaling.delete()
        if self.serviceDiscovery:
            self.serviceDiscovery.delete()
        if self.exists():
            self.ecs.delete_service(
                cluster=self.clusterName,
                service=self.serviceName,
            )

    def _show_current_status(self):
        response = self.__get_service()
        # print response
        status = response['status']
        events = response['events']
        desired_count = response['desiredCount']
        if status == 'ACTIVE':
            success = True
        else:
            success = False

        deployments = response['deployments']
        if len(deployments) > 1:
            success = False

        print("Deployment Desired Pending Running")
        for deploy in deployments:
            if deploy['desiredCount'] != deploy['runningCount']:
                success = False
            print(deploy['status'], deploy['desiredCount'], deploy['pendingCount'], deploy['runningCount'])

        print("")

        print("Service:")
        for index, event in enumerate(events):
            if index <= 5:
                print(event['message'])

        if self.load_balancer:
            if isinstance(self.load_balancer, dict):
                lbtype = 'elb'
            else:
                lbtype = 'alb'
        else:
            lbtype = None
        if lbtype == 'elb':
            print("")
            print("Load Balancer")
            elb = get_boto3_session().client('elb')
            response = elb.describe_instance_health(LoadBalancerName=self.load_balancer['load_balancer_name'])
            states = response['InstanceStates']
            if len(states) < desired_count:
                success = False
            for state in states:
                if state['State'] != "InService" or state['Description'] != "N/A":
                    success = False
                print(state['InstanceId'], state['State'], state['Description'])
        elif lbtype == 'alb':
            for target_group in self.load_balancer:
                print("")
                print("Target Group: {}".format(target_group['target_group_arn']))
                alb = get_boto3_session().client('elbv2')
                response = alb.describe_target_health(TargetGroupArn=target_group['target_group_arn'])
                if len(response['TargetHealthDescriptions']) < desired_count:
                    success = False
                for desc in response['TargetHealthDescriptions']:
                    if desc['TargetHealth']['State'] != 'healthy':
                        success = False
                    print(
                        desc['Target']['Id'],
                        desc['TargetHealth']['State'],
                        desc['TargetHealth'].get('Description', '')
                    )
        return success

    def wait_until_stable(self):
        """
        Wait until AWS reports the service as "stable".
        """
        tz = tzlocal.get_localzone()
        self.its_run_start_time = datetime.now(tz)

        for i in range(40):
            time.sleep(15)
            success = self._show_current_status()
            if success:
                print("\nDeployment successful.\n")
                return True
            else:
                print("\nDeployment unready\n")

        print('Deployment failed...')

        return False

    def run_task(self, command):
        """
        Runs the service tasks.

        :param command: Docker command to run.
        :return: ``None``
        """
        for task in self.tasks.values():
            if command in task.commands:
                return task.run(command)
        return None

    def get_config(self):
        """
        Return the ``ParameterStore()`` for our service.

        :rtype: a ``deployfish.systems_manager.ParameterStore`` object
        """
        self.parameter_store.populate()
        return self.parameter_store

    def write_config(self):
        """
        Update the AWS System Manager Parameter Store parameters to match
        what we have defined in our ``deployfish.yml``.
        """
        self.parameter_store.save()

    def _get_cluster_hosts(self):
        """
        For our service, return a mapping of ``containerInstanceArn`` to EC2
        ``instance_id`` for all container instances in our cluster.

        :rtype: dict
        """
        hosts = {}
        response = self.ecs.list_container_instances(cluster=self.clusterName)
        response = self.ecs.describe_container_instances(
            cluster=self.clusterName,
            containerInstances=response['containerInstanceArns']
        )
        instances = response['containerInstances']
        for i in instances:
            hosts[i['containerInstanceArn']] = i['ec2InstanceId']
        return hosts

    def _get_running_host(self, hosts=None):
        """
        Return the EC2 instance id for a host in our cluster which is
        running one of our service's tasks.

        :param hosts: (optional) A dict of ``containerInstanceArn`` -> EC2 ``instance_id``
        :type hosts: dict

        :rtype: string
        """
        if not hosts:
            hosts = self._get_cluster_hosts()

        instanceArns = []
        response = self.ecs.list_tasks(cluster=self.clusterName,
                                       family=self.family,
                                       desiredStatus='RUNNING')
        if response['taskArns']:
            response = self.ecs.describe_tasks(cluster=self.clusterName,
                                               tasks=response['taskArns'])
            if response['tasks']:
                task = response['tasks'][0]
                instanceArns.append(task['containerInstanceArn'])

        if instanceArns:
            for instance in instanceArns:
                if instance in hosts:
                    host = hosts[instance]
                    return host
        else:
            return None

    def get_instance_data(self):
        """
        Returns data on the instances in the ECS cluster.

        :return: list
        """
        self._search_hosts()
        instances = self.hosts.values()
        ec2 = get_boto3_session().client('ec2')
        response = ec2.describe_instances(InstanceIds=list(instances))
        if response['Reservations']:
            instances = response['Reservations']
            return instances
        return []

    def get_instances(self):
        if self.instances:
            return self.instances

        instance_data = self.get_instance_data()
        instances = []
        for index, reservation in enumerate(instance_data):
            instances.append(reservation['Instances'][0])

        for instance in instances:
            self.instances.append(Instance(instance))

        return self.instances

    def get_host_ips(self):
        """
        Returns the IP addresses of the ECS cluster instances.

        :return: list
        """
        if self.host_ips:
            return self.host_ips

        instances = self.get_instance_data()
        self.host_ips = []
        for reservation in instances:
            instance = reservation['Instances'][0]
            # print(instance)
            self.host_ips.append(instance['PrivateIpAddress'])
        return self.host_ips

    def _get_host_bastion(self, instance_id):
        """
        Given an EC2 ``instance_id`` return the private IP address of
        the instance identified by ``instance_id`` and the public
        DNS name of the bastion host you would use to reach it via ssh.

        :param instance_id: an EC2 instance id
        :type instance_id: string

        :rtype: 2-tuple (instance_private_ip_address, bastion_host_dns_name)
        """
        ip = None
        vpc_id = None
        bastion = ''
        ec2 = get_boto3_session().client('ec2')
        response = ec2.describe_instances(InstanceIds=[instance_id])
        if response['Reservations']:
            instances = response['Reservations'][0]['Instances']
            if instances:
                instance = instances[0]
                vpc_id = instance['VpcId']
                ip = instance['PrivateIpAddress']
        if ip and vpc_id:
            response = ec2.describe_instances(
                Filters=[
                    {
                        'Name': 'tag:Name',
                        'Values': ['bastion*']
                    },
                    {
                        'Name': 'vpc-id',
                        'Values': [vpc_id]
                    }
                ]
            )
            if response['Reservations']:
                instances = response['Reservations'][0]['Instances']
                if instances:
                    instance = instances[0]
                    bastion = instance['PublicDnsName']
        return ip, bastion

    def _search_hosts(self):
        if self.searched_hosts:
            return

        self.searched_hosts = True

        hosts = self._get_cluster_hosts()
        running_host = self._get_running_host(hosts)

        if running_host:
            self.is_running = True

        if running_host:
            host = running_host
        else:
            # just grab one
            for k, host in hosts.items():
                break

        self.host_instance = host

        self.hosts = hosts
        self.host_ip, self.bastion = self._get_host_bastion(host)

    def __str__(self):
        return json.dumps(self._render("to-be-created"), indent=2, sort_keys=True)
コード例 #2
0
class Task(object):
    """
    An object representing an ECS task.
    """
    def __init__(self, name, service=False, config=None):
        if service:
            yml = config.get_service(name)
        else:
            yml = config.get_task(name)

        self.ecs = get_boto3_session().client('ecs')

        self.taskName = None
        self.clusterName = None
        self.desired_count = 1
        self._launchType = 'EC2'
        self.cluster_specified = False
        self.__defaults()
        self.from_yaml(yml)
        self.from_aws()
        self.scheduler = TaskScheduler(self)

    def __defaults(self):
        self._roleArn = None
        self.schedule_expression = None
        self.schedule_role = None
        self.vpc_configuration = {}
        self.placement_constraints = []
        self.placement_strategy = []
        self.platform_version = "LATEST"
        self.cluster_arn = ''
        self.group = None

    def set_vpc_configuration(self, yml):
        self.vpc_configuration = {
            'subnets': yml['subnets'],
        }
        if 'security_groups' in yml:
            self.vpc_configuration['securityGroups'] = yml['security_groups']

        if 'public_ip' in yml:
            self.vpc_configuration['assignPublicIp'] = yml['public_ip']

    def __render(self, task_definition_id):
        """
        Generate the dict we will pass to boto3's `run_task()`.

        :rtype: dict
        """
        r = {}
        if self.cluster_specified:
            r['cluster'] = self.clusterName
        if self.desired_count:
            r['count'] = self.desired_count
        r['launchType'] = self.launchType
        if self.launchType == 'FARGATE':
            r['networkConfiguration'] = {
                'awsvpcConfiguration': self.vpc_configuration
            }
        r['taskDefinition'] = task_definition_id
        if len(self.placement_constraints) > 0:
            r['placementConstraints'] = self.placement_constraints
        if len(self.placement_strategy) > 0:
            r['placementStrategy'] = self.placement_strategy
        if self.group:
            r['group'] = self.group
        return r

    def _get_cluster_arn(self):
        if self.cluster_specified:
            response = self.ecs.describe_clusters(clusters=[self.clusterName])
            for cluster in response['clusters']:
                self.cluster_arn = cluster['clusterArn']
                return

        response = self.ecs.describe_clusters()
        for cluster in response['clusters']:
            self.cluster_arn = cluster['clusterArn']
            return

    def from_yaml(self, yml):
        """
        Load our task information from the parsed yaml.  ``yml`` should be
        a task level entry from the ``deployfish.yml`` file.

        :param yml: a task level entry from the ``deployfish.yml`` file
        :type yml: dict
        """
        self.taskName = yml['name']
        if 'launch_type' in yml:
            self.launchType = yml['launch_type']
            if self.launchType == 'FARGATE':
                if 'platform_version' in yml:
                    self.platform_version = yml['platform_version']
        self.environment = yml.get('environment', 'undefined')
        self.family = yml['family']
        if 'cluster' in yml:
            self.clusterName = yml['cluster']
            self.cluster_specified = True
        else:
            self.clusterName = 'default'

        if 'vpc_configuration' in yml:
            self.set_vpc_configuration(yml['vpc_configuration'])
        if 'placement_constraints' in yml:
            self.placementConstraints = yml['placement_constraints']
        if 'placement_strategy' in yml:
            self.placementStrategy = yml['placement_strategy']
        if 'count' in yml:
            self.desired_count = yml['count']
        self.desired_task_definition = TaskDefinition(yml=yml)
        deployfish_environment = {
            "DEPLOYFISH_TASK_NAME": yml['name'],
            "DEPLOYFISH_ENVIRONMENT": yml.get('environment', 'undefined'),
            "DEPLOYFISH_CLUSTER_NAME": self.clusterName
        }
        self.desired_task_definition.inject_environment(deployfish_environment)
        parameters = []
        if 'config' in yml:
            parameters = yml['config']
        self.parameter_store = ParameterStore("task-{}".format(self.taskName),
                                              self.clusterName,
                                              yml=parameters)
        if 'schedule' in yml:
            self.schedule_expression = yml['schedule']
        if 'schedule_role' in yml:
            self.schedule_role = yml['schedule_role']
        if 'group' in yml:
            self.group = yml['group']

        self._get_cluster_arn()

    def from_aws(self):
        """
        Update our task definition from the most recent version in AWS.
        """
        task_definition_id = self.desired_task_definition.get_latest_revision()
        if task_definition_id:
            self.active_task_definition = TaskDefinition(task_definition_id)
        else:
            self.active_task_definition = None

    def get_config(self):
        """
        Return the ``ParameterStore()`` for our task.

        :rtype: a ``deployfish.systems_manager.ParameterStore`` object
        """
        self.parameter_store.populate()
        return self.parameter_store

    def write_config(self):
        """
        Update the AWS System Manager Parameter Store parameters to match
        what we have defined in our ``deployfish.yml``.
        """
        self.parameter_store.save()

    def __force_register_task_definition(self):
        """
        Prep the parameter store and register the task definition/
        """
        self.parameter_store.populate()
        self.desired_task_definition.set_parameter_store(self.parameter_store)
        self.desired_task_definition.create()
        self.from_aws()

    def register_task_definition(self):
        """
        If our task definition has not been registered, do it here.
        """
        if not self.active_task_definition:
            self.__force_register_task_definition()

    def _get_cloudwatch_logs(self):
        """
        Retrieve and display the logs corresponding to our task until there are no more available.
        """
        if not self.active_task_definition.containers[
                0].logConfiguration.driver == 'awslogs':
            return

        prefix = self.active_task_definition.containers[
            0].logConfiguration.options['awslogs-stream-prefix']
        group = self.active_task_definition.containers[
            0].logConfiguration.options['awslogs-group']
        container = self.active_task_definition.containers[0].name
        task_id = self.taskarn.split(':')[-1][5:]
        stream = "{}/{}/{}".format(prefix, container, task_id)

        log_client = get_boto3_session().client('logs')

        nextToken = None
        kwargs = {
            'logGroupName': group,
            'logStreamName': stream,
            'startFromHead': True
        }

        print("Waiting for logs...\n")
        for i in range(40):
            time.sleep(5)
            response = log_client.get_log_events(**kwargs)
            for event in response['events']:
                print(event['message'])
            token = response['nextForwardToken']
            if token == nextToken:
                return
            nextToken = response['nextForwardToken']
            kwargs['nextToken'] = nextToken

    def _wait_until_stopped(self):
        """
        Inspect and display the status of the task until it has finished.
        """
        if 'tasks' in self.response and len(self.response['tasks']) > 0:
            task = self.response['tasks'][0]
            cluster = task['clusterArn']
            self.taskarn = task['taskArn']
            print("Waiting for task to complete...\n")
            for i in range(40):
                time.sleep(5)
                response = self.ecs.describe_tasks(cluster=cluster,
                                                   tasks=[self.taskarn])
                if 'tasks' in response and len(response['tasks']) > 0:
                    status = response['tasks'][0]['lastStatus']
                    print("\tCurrent status: {}".format(status))
                    if status == "STOPPED":
                        print("")
                        stopCode = response['tasks'][0]['stopCode']
                        if stopCode == 'TaskFailedToStart':
                            print('Task failed to start.\n')
                            print(response['tasks'][0]['stoppedReason'])
                            success = False
                        else:
                            success = True

                        return success
                else:
                    return False
            print("Timed out after 200 seconds...")

    def run(self, wait):
        """
        Run the task. If wait is specified, show the status and logs from the task.
        :param wait: Should we wait for the task to finish and display any logs
        :type wait: bool
        """
        self.register_task_definition()
        if not self.active_task_definition:
            # problem
            return
        kwargs = self.__render(self.active_task_definition.arn)
        self.response = self.ecs.run_task(**kwargs)
        # print(self.response)
        if wait:
            success = self._wait_until_stopped()
            if success:
                self._get_cloudwatch_logs()

    def schedule(self):
        """
        If a schedule expression is defined in the yml file, schedule the task accordingly via the `TaskScheduler`
        object.
        """
        if not self.schedule_expression:
            return
        self.register_task_definition()
        self.scheduler.schedule()

    def unschedule(self):
        """
        Unschedule the task.
        """
        self.scheduler.unschedule()

    def update(self):
        """
        Update the task definition as appropriate.
        """
        self.__force_register_task_definition()

    def purge(self):
        pass
コード例 #3
0
ファイル: Service.py プロジェクト: witold-gren/deployfish
class Service(object):
    """
    An object representing an ECS service.
    """
    @classmethod
    def url(cluster, service):
        """
        Return the AWS Web Console URL for service ``service`` in ECS cluster ``cluster``
        in region ``region`` as Markdown.  Suitable for inserting into a Slack message.

        :param region: the name of a valid AWS region
        :type region: string

        :param cluster: the name of an ECS cluster
        :type cluster: string

        :param service: the name of an ECS service in cluster ``cluster``
        :type service: string

        :rtype: string
        """
        region = os.environ.get('AWS_DEFAULT_REGION', 'us-west-2')
        return u"<https://{}.console.aws.amazon.com/ecs/home?region={}#/clusters/{}/services/{}/tasks|{}>".format(
            region, region, cluster, service, service)

    def __init__(self, service_name, config=None):
        yml = config.get_service(service_name)
        self.ecs = get_boto3_session().client('ecs')

        self.__aws_service = None

        self.asg = None
        self.scaling = None
        self.serviceDiscovery = None
        self.searched_hosts = False
        self.is_running = False
        self.hosts = None
        self.host_ips = None
        self._serviceName = None
        self._clusterName = None
        self._desired_count = 0
        self._minimumHealthyPercent = None
        self._maximumPercent = None
        self._launchType = 'EC2'
        self.__service_discovery = []
        self.__defaults()
        self.from_yaml(yml)
        self.from_aws()

    def __defaults(self):
        self._roleArn = None
        self.__load_balancer = {}
        self.__vpc_configuration = {}
        self.__placement_constraints = []
        self.__placement_strategy = []
        self.__schedulingStrategy = "REPLICA"

    def __get_service(self):
        """
        If a service named ``self.serviceName`` in a cluster named
        ``self.clusterName`` exists, return its data, else return an
        empty dict.

        :rtype: dict
        """
        response = self.ecs.describe_services(cluster=self._clusterName,
                                              services=[self._serviceName])
        if response['services'] and response['services'][0][
                'status'] != 'INACTIVE':
            return response['services'][0]
        else:
            return {}

    def __getattr__(self, attr):
        """
        We have this __getattr__ here to access some attributes on the dict that AWS
        returns to us via the ``describe_services()`` call.
        """
        try:
            return self.__getattribute__(attr)
        except AttributeError:
            if attr in [
                    'deployments', 'taskDefinition', 'clusterArn',
                    'desiredCount', 'runningCount', 'pendingCount',
                    'networkConfiguration', 'executionRoleArn'
            ]:
                if self.__aws_service:
                    return self.__aws_service[attr]
                return None
            else:
                raise AttributeError

    def exists(self):
        """
        Return ``True`` if our service exists in the specified cluster in AWS,
        ``False`` otherwise.

        :rtype: boolean
        """
        if self.__aws_service:
            return True
        return False

    @property
    def count(self):
        """
        For services yet to be created, return what we want the task count
        to be when we create the service.

        For services already existing in AWS, return the actual current number
        of running tasks.

        :rtype: int
        """
        if self.__aws_service:
            self._count = self.__aws_service['runningCount']
        return self._count

    @count.setter
    def count(self, count):
        """
        Set the count of tasks this service should run.  Setting this
        has no effect if the service already exists.  Use ``Service.scale()``
        to affect this instead.

        :param count: number of tasks this service should run
        :type count: int
        """
        self._count = count

    @property
    def maximumPercent(self):
        """
        If maximumPercent is defined in deployfish.yml for our service
        return that value.

        If it is not defined in deployfish.yml, but it is defined in AWS, return
        the AWS maximumPercent value.

        Else, return 200.

        :rtype: int
        """
        if not self._maximumPercent:
            if self.__aws_service:
                self._maximumPercent = self.__aws_service[
                    'deploymentConfiguration']['maximumPercent']
            else:
                # Give a reasonable default if it was not defined in deployfish.yml
                self._maximumPercent = 200
        return self._maximumPercent

    @maximumPercent.setter
    def maximumPercent(self, maximumPercent):
        """
        Set the maximum percent of tasks this service is allowed to be in the
        RUNNING or PENDING state during a deployment.  Setting this has no
        effect if the service already exists.

        :param maximumPercent: Set the maximum percent of tasks this service is allowed to run
        :type count: int
        """
        self._maximumPercent = maximumPercent

    @property
    def minimumHealthyPercent(self):
        """
        If minimumHealthyPercent is defined in deployfish.yml for our service,
        return that value.

        If it is not defined in deployfish.yml, but it is defined in AWS, return
        the AWS minimumHealthyPercent value.

        Else, return 0.

        :rtype: int
        """
        if not self._minimumHealthyPercent:
            if self.__aws_service:
                self._minimumHealthyPercent = self.__aws_service[
                    'deploymentConfiguration']['minimumHealthyPercent']
            else:
                # Give a reasonable default if it was not defined in deployfish.yml
                self._minimumHealthyPercent = 0
        return self._minimumHealthyPercent

    @minimumHealthyPercent.setter
    def minimumHealthyPercent(self, minimumHealthyPercent):
        """
        Set the minimum percent of tasks this service must maintain in the
        RUNNING or PENDING state during a deployment.  Setting this has no
        effect if the service already exists.

        :param maximumPercent: Set the minimum percent of tasks this service must maintain
        :type count: int
        """
        self._minimumHealthyPercent = minimumHealthyPercent

    @property
    def serviceName(self):
        """
        Return the name of our service.

        :rtype: string
        """
        if self.__aws_service:
            self._serviceName = self.__aws_service['serviceName']
        return self._serviceName

    @serviceName.setter
    def serviceName(self, serviceName):
        self._serviceName = serviceName

    @property
    def launchType(self):
        """
        Return the launch type of our service.

        :rtype: string
        """
        if self.__aws_service:
            self._launchType = self.__aws_service['launchType']
        return self._launchType

    @launchType.setter
    def launchType(self, launchType):
        self._launchType = launchType

    @property
    def clusterName(self):
        """
        Return the name of the cluster our service is or will be running in.

        :rtype: string
        """
        if self.__aws_service:
            self._clusterName = os.path.basename(
                self.__aws_service['clusterArn'])
        return self._clusterName

    @clusterName.setter
    def clusterName(self, clusterName):
        self._clusterName = clusterName

    @property
    def roleArn(self):
        if self.__aws_service:
            self._roleArn = self.__aws_service['roleArn']
        return self._roleArn

    @roleArn.setter
    def roleArn(self, roleArn):
        self._roleArn = roleArn

    @property
    def client_token(self):
        token = 'token-{}-{}'.format(self.serviceName, self.clusterName)
        if len(token) > 36:
            token = token[0:35]
        return token

    @property
    def active_deployment(self):
        for deployment in self.deployments:
            if deployment['taskDefinition'] == self.taskDefinition:
                break
        return deployment

    def kill_task(self, task_arn):
        """
        Kill off one of our tasks.  Do nothing if the task doesn't belong to
        this service.

        :param task_arn: the ARN of an existing task in our service
        :type task_arn: string
        """
        if task_arn in self.task_arns:
            self.ecs.stop_task(cluster=self.clusterName, task=task_arn)

    def restart(self, hard=False):
        """
        Kill off tasks in the our service one by one, letting them be
        replaced by tasks from the same task definition.  This effectively
        "restarts" the tasks.

        :param hard: if True, kill off all running tasks instantly
        :type hard: boolean
        """
        for task_arn in self.task_arns:
            self.kill_task(task_arn)
            if not hard:
                self.wait_until_stable()
        if hard:
            self.wait_until_stable()

    @property
    def task_arns(self):
        """
        Returns a list of taskArns for all tasks currently running in the service.

        :rtype: list ot strings
        """
        response = self.ecs.list_tasks(cluster=self.clusterName,
                                       serviceName=self.serviceName)
        return response['taskArns']

    @property
    def load_balancer(self):
        """
        Returns the load balancer, either elb or alb, if it exists.

        :return: dict
        """
        if self.__aws_service:
            if self.__aws_service['loadBalancers']:
                if 'loadBalancerName' in self.__aws_service['loadBalancers'][
                        0]:
                    self.__load_balancer = {
                        'type':
                        'elb',
                        'load_balancer_name':
                        self.__aws_service['loadBalancers'][0]
                        ['loadBalancerName'],
                    }
                else:
                    self.__load_balancer = {
                        'type':
                        'alb',
                        'target_group_arn':
                        self.__aws_service['loadBalancers'][0]
                        ['targetGroupArn'],
                    }
                self.__load_balancer['container_name'] = self.__aws_service[
                    'loadBalancers'][0]['containerName']
                self.__load_balancer['container_port'] = self.__aws_service[
                    'loadBalancers'][0]['containerPort']
        return self.__load_balancer

    def set_elb(self, load_balancer_name, container_name, container_port):
        self.__load_balancer = {
            'type': 'elb',
            'load_balancer_name': load_balancer_name,
            'container_name': container_name,
            'container_port': container_port
        }

    def set_alb(self, target_group_arn, container_name, container_port):
        self.__load_balancer = {
            'type': 'alb',
            'target_group_arn': target_group_arn,
            'container_name': container_name,
            'container_port': container_port
        }

    @property
    def vpc_configuration(self):
        if self.__aws_service and self.__aws_service[
                'networkConfiguration'] and not self.__vpc_configuration:
            self.__vpc_configuration = self.__aws_service[
                'networkConfiguration']['awsvpcConfiguration']
        return self.__vpc_configuration

    def set_vpc_configuration(self, subnets, security_groups, public_ip):
        self.__vpc_configuration = {
            'subnets': subnets,
            'securityGroups': security_groups,
            'assignPublicIp': public_ip
        }

    @property
    def service_discovery(self):
        if self.__aws_service:
            if self.__aws_service['serviceRegistries']:
                if 'registryArn' in self.__aws_service['serviceRegistries'][0]:
                    self.__service_discovery = self.__aws_service[
                        'serviceRegistries']
        return self.__service_discovery

    @service_discovery.setter
    def service_discovery(self, arn):
        self.__service_discovery = [{'registryArn': arn}]

    def version(self):
        if self.active_task_definition:
            if self.load_balancer:
                for c in self.active_task_definition.containers:
                    if c.name == self.load_balancer['container_name']:
                        return c.image.split(":")[1]
            else:
                # Just give the first container's version?
                return self.active_task_definition.containers[0].image.split(
                    ":")[1]
        return None

    @property
    def placementConstraints(self):
        if self.__aws_service:
            if self.__aws_service['placementConstraints']:
                self.__placement_constraints = self.__aws_service[
                    'placementConstraints']
        return self.__placement_constraints

    @placementConstraints.setter
    def placementConstraints(self, placementConstraints):
        if isinstance(placementConstraints, list):
            self.__placement_constraints = []
            for placement in placementConstraints:
                configDict = {'type': placement['type']}
                if 'expression' in placement:
                    configDict['expression'] = placement['expression']
                self.__placement_constraints.append(configDict)

    @property
    def placementStrategy(self):
        if self.__aws_service:
            if self.__aws_service['placementStrategy']:
                self.__placement_strategy = self.__aws_service[
                    'placementStrategy']
        return self.__placement_strategy

    @placementStrategy.setter
    def placementStrategy(self, placementStrategy):
        if isinstance(placementStrategy, list):
            self.__placement_strategy = []
            for placement in placementStrategy:
                configDict = {'type': placement['type']}
                if 'field' in placement:
                    configDict['field'] = placement['field']
                self.__placement_strategy.append(configDict)

    @property
    def schedulingStrategy(self):
        if self.__aws_service:
            if self.__aws_service['schedulingStrategy']:
                self.__schedulingStrategy = self.__aws_service[
                    'schedulingStrategy']
        return self.__schedulingStrategy

    @schedulingStrategy.setter
    def schedulingStrategy(self, schedulingStrategy):
        self.__schedulingStrategy = schedulingStrategy

    def __render(self, task_definition_id):
        """
        Generate the dict we will pass to boto3's `create_service()`.

        :rtype: dict
        """
        r = {}
        r['cluster'] = self.clusterName
        r['serviceName'] = self.serviceName
        r['launchType'] = self.launchType
        if self.load_balancer:
            if self.launchType != 'FARGATE':
                r['role'] = self.roleArn
            r['loadBalancers'] = []
            if self.load_balancer['type'] == 'elb':
                r['loadBalancers'].append({
                    'loadBalancerName':
                    self.load_balancer['load_balancer_name'],
                    'containerName':
                    self.load_balancer['container_name'],
                    'containerPort':
                    self.load_balancer['container_port'],
                })
            else:
                r['loadBalancers'].append({
                    'targetGroupArn':
                    self.load_balancer['target_group_arn'],
                    'containerName':
                    self.load_balancer['container_name'],
                    'containerPort':
                    self.load_balancer['container_port'],
                })
        if self.launchType == 'FARGATE':
            r['networkConfiguration'] = {
                'awsvpcConfiguration': self.vpc_configuration
            }
        r['taskDefinition'] = task_definition_id
        if self.schedulingStrategy != "DAEMON":
            r['desiredCount'] = self.count
        r['clientToken'] = self.client_token
        if self.__service_discovery:
            r['serviceRegistries'] = self.__service_discovery
        r['deploymentConfiguration'] = {
            'maximumPercent': self.maximumPercent,
            'minimumHealthyPercent': self.minimumHealthyPercent
        }
        if len(self.placementConstraints) > 0:
            r['placementConstraints'] = self.placementConstraints
        if len(self.placementStrategy) > 0:
            r['placementStrategy'] = self.placementStrategy
        if self.schedulingStrategy:
            r['schedulingStrategy'] = self.schedulingStrategy
        return r

    def from_yaml(self, yml):
        """
        Load our service information from the parsed yaml.  ``yml`` should be
        a service level entry from the ``deployfish.yml`` file.

        :param yml: a service level entry from the ``deployfish.yml`` file
        :type yml: dict
        """
        self.serviceName = yml['name']
        self.clusterName = yml['cluster']
        if 'launch_type' in yml:
            self.launchType = yml['launch_type']
        self.environment = yml.get('environment', 'undefined')
        self.family = yml['family']
        # backwards compatibility for deployfish.yml < 0.16.0
        if 'maximum_percent' in yml:
            self.maximumPercent = yml['maximum_percent']
            self.minimumHealthyPercent = yml['minimum_healthy_percent']
        self.asg = ASG(yml=yml)
        if 'application_scaling' in yml:
            self.scaling = ApplicationAutoscaling(
                yml['name'], yml['cluster'], yml=yml['application_scaling'])
        if 'load_balancer' in yml:
            if 'service_role_arn' in yml:
                # backwards compatibility for deployfish.yml < 0.3.6
                self.roleArn = yml['service_role_arn']
            else:
                self.roleArn = yml['load_balancer']['service_role_arn']
            if 'load_balancer_name' in yml['load_balancer']:
                self.set_elb(
                    yml['load_balancer']['load_balancer_name'],
                    yml['load_balancer']['container_name'],
                    yml['load_balancer']['container_port'],
                )
            elif 'target_group_arn' in yml['load_balancer']:
                self.set_alb(
                    yml['load_balancer']['target_group_arn'],
                    yml['load_balancer']['container_name'],
                    yml['load_balancer']['container_port'],
                )
        if 'vpc_configuration' in yml:
            self.set_vpc_configuration(
                yml['vpc_configuration']['subnets'],
                yml['vpc_configuration']['security_groups'],
                yml['vpc_configuration']['public_ip'],
            )
        if 'network_mode' in yml:
            if yml['network_mode'] == 'awsvpc' and 'service_discovery' in yml:
                self.serviceDiscovery = ServiceDiscovery(
                    None, yml=yml['service_discovery'])
            elif 'service_discovery' in yml:
                print(
                    "Ignoring service discovery config since network mode is not awsvpc"
                )
        if 'placement_constraints' in yml:
            self.placementConstraints = yml['placement_constraints']
        if 'placement_strategy' in yml:
            self.placementStrategy = yml['placement_strategy']
        if 'scheduling_strategy' in yml and yml[
                'scheduling_strategy'] == 'DAEMON':
            self.schedulingStrategy = yml['scheduling_strategy']
            self._count = 'automatically'
            self.maximumPercent = 100
        else:
            self._count = yml['count']
            self._desired_count = self._count
        self.desired_task_definition = TaskDefinition(yml=yml)
        deployfish_environment = {
            "DEPLOYFISH_SERVICE_NAME": yml['name'],
            "DEPLOYFISH_ENVIRONMENT": yml.get('environment', 'undefined'),
            "DEPLOYFISH_CLUSTER_NAME": yml['cluster']
        }
        self.desired_task_definition.inject_environment(deployfish_environment)
        self.tasks = {}
        if 'tasks' in yml:
            for task in yml['tasks']:
                t = HelperTask(yml['cluster'], yml=task)
                self.tasks[t.family] = t
        parameters = []
        if 'config' in yml:
            parameters = yml['config']
        self.parameter_store = ParameterStore(self._serviceName,
                                              self._clusterName,
                                              yml=parameters)

    def from_aws(self):
        """
        Update our service definition, task definition and tasks from the live
        versions in AWS.
        """
        self.__aws_service = self.__get_service()
        if not self.scaling:
            # This only gets executed if we don't have an "application_scaling"
            # section in our service YAML definition.
            #
            # But we're looking here for an autoscaling setup that we previously
            # had created but which we no longer want
            self.scaling = ApplicationAutoscaling(self.serviceName,
                                                  self.clusterName)
            if not self.scaling.exists():
                self.scaling = None
        if self.__aws_service:
            self.active_task_definition = TaskDefinition(self.taskDefinition)
            # If we have helper tasks, update them from AWS now
            helpers = self.active_task_definition.get_helper_tasks()
            if helpers:
                for t in self.tasks.values():
                    t.from_aws(helpers[t.family])

            if self.__aws_service['serviceRegistries']:
                self.serviceDiscovery = ServiceDiscovery(
                    self.service_discovery[0]['registryArn'])
            else:
                self.serviceDiscovery = None
        else:
            self.active_task_definition = None

    def __create_tasks_and_task_definition(self):
        """
        Create the new task definition for our service.

        If we have any helper tasks associated with our service, create
        them first, then and pass their information into the service
        task definition.
        """
        family_revisions = []
        for task in self.tasks.values():
            task.create()
            family_revisions.append(task.family_revision)
        self.desired_task_definition.update_task_labels(family_revisions)
        self.desired_task_definition.create()

    def create(self):
        """
        Create the service in AWS.  If necessary, setup Application Scaling afterwards.
        """
        if self.serviceDiscovery is not None:
            if not self.serviceDiscovery.exists():
                self.service_discovery = self.serviceDiscovery.create()
            else:
                print("Service Discovery already exists with this name")
        self.__create_tasks_and_task_definition()
        kwargs = self.__render(self.desired_task_definition.arn)
        self.ecs.create_service(**kwargs)
        if self.scaling:
            self.scaling.create()
        self.__defaults()
        self.from_aws()

    def update(self):
        """
        Update the service and Application Scaling setup (if any).

        If we currently don't have Application Scaling enabled, but we want it now,
        set it up appropriately.

        If we currently do have Application Scaling enabled, but it's setup differently
        than we want it, update it appropriately.

        If we currently do have Application Scaling enabled, but we no longer want it,
        remove Application Scaling.
        """
        self.update_service()
        self.update_scaling()

    def update_service(self):
        """
        Update the taskDefinition and deploymentConfiguration on the service.
        """
        self.__create_tasks_and_task_definition()
        self.ecs.update_service(
            cluster=self.clusterName,
            service=self.serviceName,
            taskDefinition=self.desired_task_definition.arn,
            deploymentConfiguration={
                'maximumPercent': self.maximumPercent,
                'minimumHealthyPercent': self.minimumHealthyPercent
            })
        self.__defaults()
        self.from_aws()

    def update_scaling(self):
        if self.scaling:
            if self.scaling.should_exist():
                if not self.scaling.exists():
                    self.scaling.create()
                else:
                    self.scaling.update()
            else:
                if self.scaling.exists():
                    self.scaling.delete()

    def scale(self, count):
        """
        Update ``desiredCount`` on our service to ``count``.

        :param count: set # of containers on our service to this
        :type count: integer
        """
        self.ecs.update_service(cluster=self.clusterName,
                                service=self.serviceName,
                                desiredCount=count)
        self._desired_count = count
        self.__defaults()
        self.from_aws()

    def delete(self):
        """
        Delete the service from AWS, as well as any related Application Scaling
        objects or service discovery objects.
        """

        # We need to delete any autoscaling stuff before deleting the service
        # because we want to delete the cloudwatch alarms associated with our
        # scaling policies.  If we delete the service first, ECS will happily
        # auto-delete the scaling target and scaling polices, but leave the
        # cloudwatch alarms hanging.  Then when we go to remove the scaling,
        # we won't know how to lookup the alarms
        if self.scaling and self.scaling.exists():
            self.scaling.delete()
        if self.serviceDiscovery:
            self.serviceDiscovery.delete()
        if self.exists():
            self.ecs.delete_service(
                cluster=self.clusterName,
                service=self.serviceName,
            )

    def _show_current_status(self):
        response = self.__get_service()
        # print response
        status = response['status']
        events = response['events']
        desired_count = response['desiredCount']
        if status == 'ACTIVE':
            success = True
        else:
            success = False

        deployments = response['deployments']
        if len(deployments) > 1:
            success = False

        print("Deployment Desired Pending Running")
        for deploy in deployments:
            if deploy['desiredCount'] != deploy['runningCount']:
                success = False
            print(deploy['status'], deploy['desiredCount'],
                  deploy['pendingCount'], deploy['runningCount'])

        print("")

        print("Service:")
        for index, event in enumerate(events):
            if index <= 5:
                print(event['message'])

        if self.load_balancer and 'type' in self.load_balancer:
            lbtype = self.load_balancer['type']
        else:
            lbtype = None
        if lbtype == 'elb':
            print("")
            print("Load Balancer")
            elb = get_boto3_session().client('elb')
            response = elb.describe_instance_health(
                LoadBalancerName=self.load_balancer['load_balancer_name'])
            states = response['InstanceStates']
            if len(states) < desired_count:
                success = False
            for state in states:
                if state['State'] != "InService" or state[
                        'Description'] != "N/A":
                    success = False
                print(state['InstanceId'], state['State'],
                      state['Description'])
        elif lbtype == 'alb':
            print("")
            print("Load Balancer")
            alb = get_boto3_session().client('elbv2')
            response = alb.describe_target_health(
                TargetGroupArn=self.load_balancer['target_group_arn'])
            if len(response['TargetHealthDescriptions']) < desired_count:
                success = False
            for desc in response['TargetHealthDescriptions']:
                if desc['TargetHealth']['State'] != 'healthy':
                    success = False
                print(desc['Target']['Id'], desc['TargetHealth']['State'],
                      desc['TargetHealth'].get('Description', ''))
        return success

    def wait_until_stable(self):
        """
        Wait until AWS reports the service as "stable".
        """
        tz = tzlocal.get_localzone()
        self.its_run_start_time = datetime.now(tz)

        for i in range(40):
            time.sleep(15)
            success = self._show_current_status()
            if success:
                print("\nDeployment successful.\n")
                return True
            else:
                print("\nDeployment unready\n")

        print('Deployment failed...')

        # waiter = self.ecs.get_waiter('services_stable')
        # waiter.wait(cluster=self.clusterName, services=[self.serviceName])
        return False

    def run_task(self, command):
        """
        Runs the service tasks.

        :param command: Docker command to run.
        :return: ``None``
        """
        for task in self.tasks.values():
            if command in task.commands:
                return task.run(command)
        return None

    def get_config(self):
        """
        Return the ``ParameterStore()`` for our service.

        :rtype: a ``deployfish.systems_manager.ParameterStore`` object
        """
        self.parameter_store.populate()
        return self.parameter_store

    def write_config(self):
        """
        Update the AWS System Manager Parameter Store parameters to match
        what we have defined in our ``deployfish.yml``.
        """
        self.parameter_store.save()

    def _get_cluster_hosts(self):
        """
        For our service, return a mapping of ``containerInstanceArn`` to EC2
        ``instance_id`` for all container instances in our cluster.

        :rtype: dict
        """
        hosts = {}
        response = self.ecs.list_container_instances(cluster=self.clusterName)
        response = self.ecs.describe_container_instances(
            cluster=self.clusterName,
            containerInstances=response['containerInstanceArns'])
        instances = response['containerInstances']
        for i in instances:
            hosts[i['containerInstanceArn']] = i['ec2InstanceId']
        return hosts

    def _get_running_host(self, hosts=None):
        """
        Return the EC2 instance id for a host in our cluster which is
        running one of our service's tasks.

        :param hosts: (optional) A dict of ``containerInstanceArn`` -> EC2 ``instance_id``
        :type hosts: dict

        :rtype: string
        """
        if not hosts:
            hosts = self._get_cluster_hosts()

        instanceArns = []
        response = self.ecs.list_tasks(cluster=self.clusterName,
                                       family=self.family,
                                       desiredStatus='RUNNING')
        if response['taskArns']:
            response = self.ecs.describe_tasks(cluster=self.clusterName,
                                               tasks=response['taskArns'])
            if response['tasks']:
                task = response['tasks'][0]
                instanceArns.append(task['containerInstanceArn'])

        if instanceArns:
            for instance in instanceArns:
                if instance in hosts:
                    host = hosts[instance]
                    return host
        else:
            return None

    def get_instance_data(self):
        """
        Returns data on the instances in the ECS cluster.

        :return: list
        """
        self._search_hosts()
        instances = self.hosts.values()
        ec2 = get_boto3_session().client('ec2')
        response = ec2.describe_instances(InstanceIds=list(instances))
        if response['Reservations']:
            instances = response['Reservations']
            return instances
        return []

    def get_host_ips(self):
        """
        Returns the IP addresses of the ECS cluster instances.

        :return: list
        """
        if self.host_ips:
            return self.host_ips

        instances = self.get_instance_data()
        self.host_ips = []
        for reservation in instances:
            instance = reservation['Instances'][0]
            self.host_ips.append(instance['PrivateIpAddress'])
        return self.host_ips

    def cluster_run(self, cmd):
        """
        Run a command on each of the ECS cluster machines.

        :param cmd: Linux command to run.

        :return: list of tuples
        """
        ips = self.get_host_ips()
        host_ip = self.host_ip
        responses = []
        for ip in ips:
            self.host_ip = ip
            success, output = self.run_remote_script(cmd)
            responses.append((success, output))
        self.host_ip = host_ip
        return responses

    def cluster_ssh(self, ip):
        """
        SSH into the specified ECS cluster instance.

        :param ip: ECS cluster instance IP address

        :return: ``None``
        """
        self.host_ip = ip
        self.ssh()

    def _get_host_bastion(self, instance_id):
        """
        Given an EC2 ``instance_id`` return the private IP address of
        the instance identified by ``instance_id`` and the public
        DNS name of the bastion host you would use to reach it via ssh.

        :param instance_id: an EC2 instance id
        :type instance_id: string

        :rtype: 2-tuple (instance_private_ip_address, bastion_host_dns_name)
        """
        ip = None
        vpc_id = None
        bastion = ''
        ec2 = get_boto3_session().client('ec2')
        response = ec2.describe_instances(InstanceIds=[instance_id])
        if response['Reservations']:
            instances = response['Reservations'][0]['Instances']
            if instances:
                instance = instances[0]
                vpc_id = instance['VpcId']
                ip = instance['PrivateIpAddress']
        if ip and vpc_id:
            response = ec2.describe_instances(Filters=[{
                'Name': 'tag:Name',
                'Values': ['bastion*']
            }, {
                'Name': 'vpc-id',
                'Values': [vpc_id]
            }])
            if response['Reservations']:
                instances = response['Reservations'][0]['Instances']
                if instances:
                    instance = instances[0]
                    bastion = instance['PublicDnsName']
        return ip, bastion

    def __is_or_has_file(self, data):
        '''
        Figure out if we have been given a file-like object as one of the inputs to the function that called this.
        Is a bit clunky because 'file' doesn't exist as a bare-word type check in Python 3 and built in file objects
        are not instances of io.<anything> in Python 2

        https://stackoverflow.com/questions/1661262/check-if-object-is-file-like-in-python
        Returns:
            Boolean - True if we have a file-like object
        '''
        if (hasattr(data, 'file')):
            data = data.file

        try:
            return isinstance(data, file)
        except NameError:
            from io import IOBase
            return isinstance(data, IOBase)

    def push_remote_text_file(self,
                              input_data=None,
                              run=False,
                              file_output=False):
        """
        Push a text file to the current remote ECS cluster instance and optionally run it.

        :param input_data: Input data to send. Either string or file.
        :param run: Boolean that indicates if the text file should be run.
        :param file_output: Boolean that indicates if the output should be saved.
        :return: tuple - success, output
        """
        if self.__is_or_has_file(input_data):
            path, name = os.path.split(input_data.name)
        else:
            name = ''.join(
                random.choice(string.ascii_uppercase + string.digits)
                for _ in range(10))

        if run:
            cmd = '"cat \> {}\;bash {}\;rm {}"'.format(name, name, name)
        else:
            cmd = '"cat \> {}"'.format(name)

        with_output = True
        if file_output:
            with_output = NamedTemporaryFile(delete=False)
            output_filename = with_output.name

        success, output = self.ssh(command=cmd,
                                   with_output=with_output,
                                   input_data=input_data)
        if file_output:
            output = output_filename
        return success, output

    def run_remote_script(self, lines, file_output=False):
        """
        Run a script on the current remote ECS cluster instance.

        :param lines: list of lines of the script.
        :param file_output: Boolean that indicates if the output should be saved.
        :return: tuple - success, output
        """
        data = '\n'.join(lines)
        return self.push_remote_text_file(input_data=data,
                                          run=True,
                                          file_output=file_output)

    def _run_command_with_io(self, cmd, output_file=None, input_data=None):
        success = True

        if output_file:
            stdout = output_file
        else:
            stdout = subprocess.PIPE

        if input_data:
            if self.__is_or_has_file(input_data):
                stdin = input_data
                input_string = None
            else:
                stdin = subprocess.PIPE
                input_string = input_data
        else:
            stdin = None

        try:
            p = subprocess.Popen(cmd,
                                 stdout=stdout,
                                 stdin=stdin,
                                 shell=True,
                                 universal_newlines=True)
            output, errors = p.communicate(input_string)
        except subprocess.CalledProcessError as err:
            success = False
            output = "{}\n{}".format(err.cmd, err.output)
            output = err.output

        return success, output

    def _search_hosts(self):
        if self.searched_hosts:
            return

        self.searched_hosts = True

        hosts = self._get_cluster_hosts()
        running_host = self._get_running_host(hosts)

        if running_host:
            self.is_running = True

        if running_host:
            host = running_host
        else:
            # just grab one
            for k, host in hosts.items():
                break

        self.hosts = hosts
        self.host_ip, self.bastion = self._get_host_bastion(host)

    def ssh(self,
            command=None,
            is_running=False,
            with_output=False,
            input_data=None,
            verbose=False):
        """
        :param is_running: only complete the ssh if a task from our service is
                           actually running in the cluster
        :type is_running: boolean
        """
        self._search_hosts()

        if is_running and not self.is_running:
            return

        if self.host_ip and self.bastion:
            if verbose:
                verbose_flag = "-vv"
            else:
                verbose_flag = "-q"
            cmd = 'ssh {} -o StrictHostKeyChecking=no -A -t ec2-user@{} ssh {} -o StrictHostKeyChecking=no -A -t {}'.format(
                verbose_flag, self.bastion, verbose_flag, self.host_ip)
            if command:
                cmd = "{} {}".format(cmd, command)

            if with_output:
                if self.__is_or_has_file(with_output):
                    output_file = with_output
                else:
                    output_file = None
                return self._run_command_with_io(cmd,
                                                 output_file=output_file,
                                                 input_data=input_data)

            subprocess.call(cmd, shell=True)

    def docker_exec(self, verbose=False):
        """
        Exec into a running Docker container.
        """
        command = "\"/usr/bin/docker exec -it '\$(/usr/bin/docker ps --filter \"name=ecs-{}*\" -q)' bash\""
        command = command.format(self.family)
        self.ssh(command, is_running=True, verbose=verbose)

    def tunnel(self, host, local_port, interim_port, host_port):
        """
        Open tunnel to remote system.
        :param host:
        :param local_port:
        :param interim_port:
        :param host_port:
        :return:
        """
        hosts = self._get_cluster_hosts()
        ecs_host = hosts[list(hosts.keys())[0]]
        host_ip, bastion = self._get_host_bastion(ecs_host)

        cmd = 'ssh -L {}:localhost:{} ec2-user@{} ssh -L {}:{}:{}  {}'.format(
            local_port, interim_port, bastion, interim_port, host, host_port,
            host_ip)
        subprocess.call(cmd, shell=True)

    def __str__(self):
        return json.dumps(self.__render("to-be-created"),
                          indent=2,
                          sort_keys=True)