
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import base64

from libcloud.utils.py3 import b, httplib, urlparse
from libcloud.common.base import JsonResponse, ConnectionUserAndKey
from libcloud.container.base import Container, ContainerImage, ContainerDriver
from libcloud.container.types import ContainerState
from libcloud.container.providers import Provider

try:
    import simplejson as json
except Exception:
    import json


VALID_RESPONSE_CODES = [
    httplib.OK,
    httplib.ACCEPTED,
    httplib.CREATED,
    httplib.NO_CONTENT,
]


class RancherResponse(JsonResponse):
    def parse_error(self):
        parsed = super().parse_error()
        if "fieldName" in parsed:
            return "Field {} is {}: {} - {}".format(
                parsed["fieldName"],
                parsed["code"],
                parsed["message"],
                parsed["detail"],
            )
        else:
            return "{} - {}".format(parsed["message"], parsed["detail"])

    def success(self):
        return self.status in VALID_RESPONSE_CODES


class RancherException(Exception):
    def __init__(self, code, message):
        self.code = code
        self.message = message
        self.args = (code, message)

    def __str__(self):
        return "{} {}".format(self.code, self.message)

    def __repr__(self):
        return "RancherException {} {}".format(self.code, self.message)


class RancherConnection(ConnectionUserAndKey):
    responseCls = RancherResponse
    timeout = 30

    def add_default_headers(self, headers):
        """
        Add parameters that are necessary for every request
        If user and password are specified, include a base http auth
        header
        """
        headers["Content-Type"] = "application/json"
        headers["Accept"] = "application/json"
        if self.key and self.user_id:
            user_b64 = base64.b64encode(b("{}:{}".format(self.user_id, self.key)))
            headers["Authorization"] = "Basic %s" % (user_b64.decode("utf-8"))
        return headers


class RancherContainerDriver(ContainerDriver):
    """
    Driver for Rancher by Rancher Labs.

    This driver is capable of interacting with the Version 1 API of Rancher.
    It currently does NOT support the Version 2 API.

    Example:

        >>> from libcloud.container.providers import get_driver
        >>> from libcloud.container.types import Provider

        >>> driver = get_driver(Provider.RANCHER)
        >>> connection = driver(key="ACCESS_KEY_HERE",
        secret="SECRET_KEY_HERE", host="172.30.0.100", port=8080)

        >>> image = ContainerImage("hastebin", "hastebin", "rlister/hastebin",
        "latest", driver=None)
        >>> newcontainer = connection.deploy_container("myawesomepastebin",
        image, environment={"STORAGE_TYPE": "file"})

    :ivar   baseuri: The URL base path to the API.
    :type   baseuri: ``str``
    """

    type = Provider.RANCHER
    name = "Rancher"
    website = "http://rancher.com"
    connectionCls = RancherConnection
    # Holding off on cluster support for now.
    # Only Environment API interaction enabled.
    supports_clusters = False
    # As in the /v1/
    version = "1"

    def __init__(self, key, secret, secure=True, host="localhost", port=443):
        """
        Creates a new Rancher Container driver.

        :param    key: API key or username to used (required)
        :type     key: ``str``

        :param    secret: Secret password to be used (required)
        :type     secret: ``str``

        :param    secure: Whether to use HTTPS or HTTP.
        :type     secure: ``bool``

        :param    host: Override hostname used for connections. This can also
         be a full URL string, including scheme, port, and base path.
        :type     host: ``str``

        :param    port: Override port used for connections.
        :type     port: ``int``

        :return: A newly initialized driver instance.
        """

        # Parse the Given Host
        if "://" not in host and not host.startswith("//"):
            host = "//" + host
        parsed = urlparse.urlparse(host)

        super().__init__(
            key=key,
            secret=secret,
            secure=False if parsed.scheme == "http" else secure,
            host=parsed.hostname,
            port=parsed.port if parsed.port else port,
        )

        self.baseuri = parsed.path if parsed.path else "/v%s" % self.version

    def ex_list_stacks(self):
        """
        List all Rancher Stacks

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/environment/

        :rtype: ``list`` of ``dict``
        """

        result = self.connection.request("%s/environments" % self.baseuri).object
        return result["data"]

    def ex_deploy_stack(
        self,
        name,
        description=None,
        docker_compose=None,
        environment=None,
        external_id=None,
        rancher_compose=None,
        start=True,
    ):
        """
        Deploy a new stack.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/environment/#create

        :param name: The desired name of the stack. (required)
        :type name: ``str``

        :param description: A desired description for the stack.
        :type description: ``str``

        :param docker_compose: The Docker Compose configuration to use.
        :type docker_compose: ``str``

        :param environment: Environment K/V specific to this stack.
        :type environment: ``dict``

        :param external_id: The externalId of the stack.
        :type external_id: ``str``

        :param rancher_compose: The Rancher Compose configuration for this env.
        :type rancher_compose: ``str``

        :param start: Whether to start this stack on creation.
        :type start: ``bool``

        :return: The newly created stack.
        :rtype: ``dict``
        """

        payload = {
            "description": description,
            "dockerCompose": docker_compose,
            "environment": environment,
            "externalId": external_id,
            "name": name,
            "rancherCompose": rancher_compose,
            "startOnCreate": start,
        }
        data = json.dumps({k: v for (k, v) in payload.items() if v is not None})
        result = self.connection.request(
            "%s/environments" % self.baseuri, data=data, method="POST"
        ).object

        return result

    def ex_get_stack(self, env_id):
        """
        Get a stack by ID

        :param env_id: The stack to be obtained.
        :type env_id: ``str``

        :rtype: ``dict``
        """
        result = self.connection.request("{}/environments/{}".format(self.baseuri, env_id)).object

        return result

    def ex_search_stacks(self, search_params):
        """
        Search for stacks matching certain filters

        i.e. ``{ "name": "awesomestack"}``

        :param search_params: A collection of search parameters to use.
        :type search_params: ``dict``

        :rtype: ``list``
        """
        search_list = []
        for f, v in search_params.items():
            search_list.append(f + "=" + v)
        search_items = "&".join(search_list)
        result = self.connection.request(
            "{}/environments?{}".format(self.baseuri, search_items)
        ).object

        return result["data"]

    def ex_destroy_stack(self, env_id):
        """
        Destroy a stack by ID

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/environment/#delete

        :param env_id: The stack to be destroyed.
        :type env_id: ``str``

        :return: True if destroy was successful, False otherwise.
        :rtype: ``bool``
        """
        result = self.connection.request(
            "{}/environments/{}".format(self.baseuri, env_id), method="DELETE"
        )
        return result.status in VALID_RESPONSE_CODES

    def ex_activate_stack(self, env_id):
        """
        Activate Services for a stack.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/environment/#activateservices

        :param env_id: The stack to activate services for.
        :type env_id: ``str``

        :return: True if activate was successful, False otherwise.
        :rtype: ``bool``
        """
        result = self.connection.request(
            "{}/environments/{}?action=activateservices".format(self.baseuri, env_id),
            method="POST",
        )
        return result.status in VALID_RESPONSE_CODES

    def ex_deactivate_stack(self, env_id):
        """
        Deactivate Services for a stack.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/environment/#deactivateservices

        :param env_id: The stack to deactivate services for.
        :type env_id: ``str``

        :return: True if deactivate was successful, False otherwise.
        :rtype: ``bool``
        """

        result = self.connection.request(
            "{}/environments/{}?action=deactivateservices".format(self.baseuri, env_id),
            method="POST",
        )
        return result.status in VALID_RESPONSE_CODES

    def ex_list_services(self):
        """
        List all Rancher Services

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/service/

        :rtype: ``list`` of ``dict``
        """

        result = self.connection.request("%s/services" % self.baseuri).object
        return result["data"]

    def ex_deploy_service(
        self,
        name,
        image,
        environment_id,
        start=True,
        assign_service_ip_address=None,
        service_description=None,
        external_id=None,
        metadata=None,
        retain_ip=None,
        scale=None,
        scale_policy=None,
        secondary_launch_configs=None,
        selector_container=None,
        selector_link=None,
        vip=None,
        **launch_conf,
    ):
        """
        Deploy a Rancher Service under a stack.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/service/#create

        *Any further configuration passed applies to the ``launchConfig``*

        :param name: The desired name of the service. (required)
        :type name: ``str``

        :param image: The Image object to deploy. (required)
        :type image: :class:`libcloud.container.base.ContainerImage`

        :param environment_id: The stack ID this service is tied to. (required)
        :type environment_id: ``str``

        :param start: Whether to start the service on creation.
        :type start: ``bool``

        :param assign_service_ip_address: The IP address to assign the service.
        :type assign_service_ip_address: ``bool``

        :param service_description: The service description.
        :type service_description: ``str``

        :param external_id: The externalId for this service.
        :type external_id: ``str``

        :param metadata: K/V Metadata for this service.
        :type metadata: ``dict``

        :param retain_ip: Whether this service should retain its IP.
        :type retain_ip: ``bool``

        :param scale: The scale of containers in this service.
        :type scale: ``int``

        :param scale_policy: The scaling policy for this service.
        :type scale_policy: ``dict``

        :param secondary_launch_configs: Secondary container launch configs.
        :type secondary_launch_configs: ``list``

        :param selector_container: The selectorContainer for this service.
        :type selector_container: ``str``

        :param selector_link: The selectorLink for this service.
        :type selector_link: ``type``

        :param vip: The VIP to assign to this service.
        :type vip: ``str``

        :return: The newly created service.
        :rtype: ``dict``
        """

        launch_conf["imageUuid"] = (self._degen_image(image),)

        service_payload = {
            "assignServiceIpAddress": assign_service_ip_address,
            "description": service_description,
            "environmentId": environment_id,
            "externalId": external_id,
            "launchConfig": launch_conf,
            "metadata": metadata,
            "name": name,
            "retainIp": retain_ip,
            "scale": scale,
            "scalePolicy": scale_policy,
            "secondary_launch_configs": secondary_launch_configs,
            "selectorContainer": selector_container,
            "selectorLink": selector_link,
            "startOnCreate": start,
            "vip": vip,
        }

        data = json.dumps({k: v for (k, v) in service_payload.items() if v is not None})
        result = self.connection.request(
            "%s/services" % self.baseuri, data=data, method="POST"
        ).object

        return result

    def ex_get_service(self, service_id):
        """
        Get a service by ID

        :param service_id: The service_id to be obtained.
        :type service_id: ``str``

        :rtype: ``dict``
        """
        result = self.connection.request("{}/services/{}".format(self.baseuri, service_id)).object

        return result

    def ex_search_services(self, search_params):
        """
        Search for services matching certain filters

        i.e. ``{ "name": "awesomesause", "environmentId": "1e2"}``

        :param search_params: A collection of search parameters to use.
        :type search_params: ``dict``

        :rtype: ``list``
        """
        search_list = []
        for f, v in search_params.items():
            search_list.append(f + "=" + v)
        search_items = "&".join(search_list)
        result = self.connection.request("{}/services?{}".format(self.baseuri, search_items)).object

        return result["data"]

    def ex_destroy_service(self, service_id):
        """
        Destroy a service by ID

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/service/#delete

        :param service_id: The service to be destroyed.
        :type service_id: ``str``

        :return: True if destroy was successful, False otherwise.
        :rtype: ``bool``
        """
        result = self.connection.request(
            "{}/services/{}".format(self.baseuri, service_id), method="DELETE"
        )
        return result.status in VALID_RESPONSE_CODES

    def ex_activate_service(self, service_id):
        """
        Activate a service.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/service/#activate

        :param service_id: The service to activate services for.
        :type service_id: ``str``

        :return: True if activate was successful, False otherwise.
        :rtype: ``bool``
        """
        result = self.connection.request(
            "{}/services/{}?action=activate".format(self.baseuri, service_id), method="POST"
        )
        return result.status in VALID_RESPONSE_CODES

    def ex_deactivate_service(self, service_id):
        """
        Deactivate a service.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/service/#deactivate

        :param service_id: The service to deactivate services for.
        :type service_id: ``str``

        :return: True if deactivate was successful, False otherwise.
        :rtype: ``bool``
        """
        result = self.connection.request(
            "{}/services/{}?action=deactivate".format(self.baseuri, service_id),
            method="POST",
        )
        return result.status in VALID_RESPONSE_CODES

    def list_containers(self):
        """
        List the deployed containers.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/container/

        :rtype: ``list`` of :class:`libcloud.container.base.Container`
        """

        result = self.connection.request("%s/containers" % self.baseuri).object
        containers = [self._to_container(value) for value in result["data"]]
        return containers

    def deploy_container(self, name, image, parameters=None, start=True, **config):
        """
        Deploy a new container.

        http://docs.rancher.com/rancher/v1.2/en/api/api-resources/container/#create

        **The following is the Image format used for ``ContainerImage``**

        *For a ``imageuuid``*:

        - ``docker:<hostname>:<port>/<namespace>/<imagename>:<version>``

        *The following applies*:

        - ``id`` = ``<imagename>``
        - ``name`` = ``<imagename>``
        - ``path`` = ``<hostname>:<port>/<namespace>/<imagename>``
        - ``version`` = ``<version>``

        *Any extra configuration can also be passed i.e. "environment"*

        :param name: The desired name of the container. (required)
        :type name: ``str``

        :param image: The Image object to deploy. (required)
        :type image: :class:`libcloud.container.base.ContainerImage`

        :param parameters: Container Image parameters (unused)
        :type  parameters: ``str``

        :param start: Whether to start the container on creation(startOnCreate)
        :type start: ``bool``

        :rtype: :class:`Container`
        """

        payload = {
            "name": name,
            "imageUuid": self._degen_image(image),
            "startOnCreate": start,
        }
        config.update(payload)

        data = json.dumps(config)

        result = self.connection.request(
            "%s/containers" % self.baseuri, data=data, method="POST"
        ).object

        return self._to_container(result)

    def get_container(self, con_id):
        """
        Get a container by ID

        :param con_id: The ID of the container to get
        :type  con_id: ``str``

        :rtype: :class:`libcloud.container.base.Container`
        """
        result = self.connection.request("{}/containers/{}".format(self.baseuri, con_id)).object

        return self._to_container(result)

    def start_container(self, container):
        """
        Start a container

        :param container: The container to be started
        :type  container: :class:`libcloud.container.base.Container`

        :return: The container refreshed with current data
        :rtype: :class:`libcloud.container.base.Container`
        """
        result = self.connection.request(
            "{}/containers/{}?action=start".format(self.baseuri, container.id),
            method="POST",
        ).object

        return self._to_container(result)

    def stop_container(self, container):
        """
        Stop a container

        :param container: The container to be stopped
        :type  container: :class:`libcloud.container.base.Container`

        :return: The container refreshed with current data
        :rtype: :class:`libcloud.container.base.Container`
        """
        result = self.connection.request(
            "{}/containers/{}?action=stop".format(self.baseuri, container.id), method="POST"
        ).object

        return self._to_container(result)

    def ex_search_containers(self, search_params):
        """
        Search for containers matching certain filters

        i.e. ``{ "imageUuid": "docker:mysql", "state": "running"}``

        :param search_params: A collection of search parameters to use.
        :type search_params: ``dict``

        :rtype: ``list``
        """
        search_list = []
        for f, v in search_params.items():
            search_list.append(f + "=" + v)
        search_items = "&".join(search_list)
        result = self.connection.request(
            "{}/containers?{}".format(self.baseuri, search_items)
        ).object

        return result["data"]

    def destroy_container(self, container):
        """
        Remove a container

        :param container: The container to be destroyed
        :type  container: :class:`libcloud.container.base.Container`

        :return: True if the destroy was successful, False otherwise.
        :rtype: ``bool``
        """
        result = self.connection.request(
            "{}/containers/{}".format(self.baseuri, container.id), method="DELETE"
        ).object

        return self._to_container(result)

    def _gen_image(self, imageuuid):
        """
        This function converts a valid Rancher ``imageUuid`` string to a valid
        image object. Only supports docker based images hence `docker:` must
        prefix!!

        Please see the deploy_container() for details on the format.

        :param imageuuid: A valid Rancher image string
            i.e. ``docker:rlister/hastebin:8.0``
        :type imageuuid: ``str``

        :return: Converted ContainerImage object.
        :rtype: :class:`libcloud.container.base.ContainerImage`
        """
        # Obtain just the name(:version) for parsing
        if "/" not in imageuuid:
            # String looks like `docker:mysql:8.0`
            image_name_version = imageuuid.partition(":")[2]
        else:
            # String looks like `docker:oracle/mysql:8.0`
            image_name_version = imageuuid.rpartition("/")[2]
        # Parse based on ':'
        if ":" in image_name_version:
            version = image_name_version.partition(":")[2]
            id = image_name_version.partition(":")[0]
            name = id
        else:
            version = "latest"
            id = image_name_version
            name = id
        # Get our path based on if there was a version
        if version != "latest":
            path = imageuuid.partition(":")[2].rpartition(":")[0]
        else:
            path = imageuuid.partition(":")[2]

        return ContainerImage(
            id=id,
            name=name,
            path=path,
            version=version,
            driver=self.connection.driver,
            extra={"imageUuid": imageuuid},
        )

    def _degen_image(self, image):
        """
        Take in an image object to break down into an ``imageUuid``

        :param image:
        :return:
        """

        # Only supporting docker atm
        image_type = "docker"

        if image.version is not None:
            return image_type + ":" + image.path + ":" + image.version
        else:
            return image_type + ":" + image.path

    def _to_container(self, data):
        """
        Convert container in proper Container instance object
        ** Updating is NOT supported!!

        :param data: API data about container i.e. result.object
        :return: Proper Container object:
         see http://libcloud.readthedocs.io/en/latest/container/api.html

        """
        rancher_state = data["state"]

        # A Removed container is purged after x amt of time.
        # Both of these render the container dead (can't be started later)
        terminate_condition = ["removed", "purged"]

        if "running" in rancher_state:
            state = ContainerState.RUNNING
        elif "stopped" in rancher_state:
            state = ContainerState.STOPPED
        elif "restarting" in rancher_state:
            state = ContainerState.REBOOTING
        elif "error" in rancher_state:
            state = ContainerState.ERROR
        elif any(x in rancher_state for x in terminate_condition):
            state = ContainerState.TERMINATED
        elif data["transitioning"] == "yes":
            # Best we can do for current actions
            state = ContainerState.PENDING
        else:
            state = ContainerState.UNKNOWN

        # Everything contained in the json response is dumped in extra
        extra = data

        return Container(
            id=data["id"],
            name=data["name"],
            image=self._gen_image(data["imageUuid"]),
            ip_addresses=[data["primaryIpAddress"]],
            state=state,
            driver=self.connection.driver,
            extra=extra,
        )
