saltstack/salt

View on GitHub
salt/cloud/clouds/linode.py

Summary

Maintainability
F
1 wk
Test Coverage
# -*- coding: utf-8 -*-
'''
Linode Cloud Module using Linode's REST API
===========================================

The Linode cloud module is used to control access to the Linode VPS system.

Use of this module only requires the ``apikey`` parameter. However, the default root password for new instances
also needs to be set. The password needs to be 8 characters and contain lowercase, uppercase, and numbers.

Set up the cloud configuration at ``/etc/salt/cloud.providers`` or ``/etc/salt/cloud.providers.d/linode.conf``:

.. code-block:: yaml

    my-linode-provider:
      apikey: f4ZsmwtB1c7f85Jdu43RgXVDFlNjuJaeIYV8QMftTqKScEB2vSosFSr...
      password: F00barbaz
      driver: linode

    linode-profile:
      provider: my-linode-provider
      size: Linode 1024
      image: CentOS 7
      location: London, England, UK

'''

# Import Python Libs
from __future__ import absolute_import, print_function, unicode_literals
import logging
import pprint
import re
import time
import datetime

# Import Salt Libs
import salt.config as config
from salt.ext import six
from salt.ext.six.moves import range
from salt.exceptions import (
    SaltCloudConfigError,
    SaltCloudException,
    SaltCloudNotFound,
    SaltCloudSystemExit
)

# Get logging started
log = logging.getLogger(__name__)

# The epoch of the last time a query was made
LASTCALL = int(time.mktime(datetime.datetime.now().timetuple()))

# Human-readable status fields (documentation: https://www.linode.com/api/linode/linode.list)
LINODE_STATUS = {
    'boot_failed': {
        'code': -2,
        'descr': 'Boot Failed (not in use)',
    },
    'beeing_created': {
        'code': -1,
        'descr': 'Being Created',
    },
    'brand_new': {
        'code': 0,
        'descr': 'Brand New',
    },
    'running': {
        'code': 1,
        'descr': 'Running',
    },
    'poweroff': {
        'code': 2,
        'descr': 'Powered Off',
    },
    'shutdown': {
        'code': 3,
        'descr': 'Shutting Down (not in use)',
    },
    'save_to_disk': {
        'code': 4,
        'descr': 'Saved to Disk (not in use)',
    },
}

__virtualname__ = 'linode'


# Only load in this module if the Linode configurations are in place
def __virtual__():
    '''
    Check for Linode configs.
    '''
    if get_configured_provider() is False:
        return False

    return __virtualname__


def get_configured_provider():
    '''
    Return the first configured instance.
    '''
    return config.is_provider_configured(
        __opts__,
        __active_provider_name__ or __virtualname__,
        ('apikey', 'password',)
    )


def avail_images(call=None):
    '''
    Return available Linode images.

    CLI Example:

    .. code-block:: bash

        salt-cloud --list-images my-linode-config
        salt-cloud -f avail_images my-linode-config
    '''
    if call == 'action':
        raise SaltCloudException(
            'The avail_images function must be called with -f or --function.'
        )

    response = _query('avail', 'distributions')

    ret = {}
    for item in response['DATA']:
        name = item['LABEL']
        ret[name] = item

    return ret


def avail_locations(call=None):
    '''
    Return available Linode datacenter locations.

    CLI Example:

    .. code-block:: bash

        salt-cloud --list-locations my-linode-config
        salt-cloud -f avail_locations my-linode-config
    '''
    if call == 'action':
        raise SaltCloudException(
            'The avail_locations function must be called with -f or --function.'
        )

    response = _query('avail', 'datacenters')

    ret = {}
    for item in response['DATA']:
        name = item['LOCATION']
        ret[name] = item

    return ret


def avail_sizes(call=None):
    '''
    Return available Linode sizes.

    CLI Example:

    .. code-block:: bash

        salt-cloud --list-sizes my-linode-config
        salt-cloud -f avail_sizes my-linode-config
    '''
    if call == 'action':
        raise SaltCloudException(
            'The avail_locations function must be called with -f or --function.'
        )

    response = _query('avail', 'LinodePlans')

    ret = {}
    for item in response['DATA']:
        name = item['LABEL']
        ret[name] = item

    return ret


def boot(name=None, kwargs=None, call=None):
    '''
    Boot a Linode.

    name
        The name of the Linode to boot. Can be used instead of ``linode_id``.

    linode_id
        The ID of the Linode to boot. If provided, will be used as an
        alternative to ``name`` and reduces the number of API calls to
        Linode by one. Will be preferred over ``name``.

    config_id
        The ID of the Config to boot. Required.

    check_running
        Defaults to True. If set to False, overrides the call to check if
        the VM is running before calling the linode.boot API call. Change
        ``check_running`` to True is useful during the boot call in the
        create function, since the new VM will not be running yet.

    Can be called as an action (which requires a name):

    .. code-block:: bash

        salt-cloud -a boot my-instance config_id=10

    ...or as a function (which requires either a name or linode_id):

    .. code-block:: bash

        salt-cloud -f boot my-linode-config name=my-instance config_id=10
        salt-cloud -f boot my-linode-config linode_id=1225876 config_id=10
    '''
    if name is None and call == 'action':
        raise SaltCloudSystemExit(
            'The boot action requires a \'name\'.'
        )

    if kwargs is None:
        kwargs = {}

    linode_id = kwargs.get('linode_id', None)
    config_id = kwargs.get('config_id', None)
    check_running = kwargs.get('check_running', True)

    if call == 'function':
        name = kwargs.get('name', None)

    if name is None and linode_id is None:
        raise SaltCloudSystemExit(
            'The boot function requires either a \'name\' or a \'linode_id\'.'
        )

    if config_id is None:
        raise SaltCloudSystemExit(
            'The boot function requires a \'config_id\'.'
        )

    if linode_id is None:
        linode_id = get_linode_id_from_name(name)
        linode_item = name
    else:
        linode_item = linode_id

    # Check if Linode is running first
    if check_running is True:
        status = get_linode(kwargs={'linode_id': linode_id})['STATUS']
        if status == '1':
            raise SaltCloudSystemExit(
                'Cannot boot Linode {0}. '
                'Linode {0} is already running.'.format(linode_item)
            )

    # Boot the VM and get the JobID from Linode
    response = _query('linode', 'boot',
                      args={'LinodeID': linode_id,
                            'ConfigID': config_id})['DATA']
    boot_job_id = response['JobID']

    if not _wait_for_job(linode_id, boot_job_id):
        log.error('Boot failed for Linode %s.', linode_item)
        return False

    return True


def clone(kwargs=None, call=None):
    '''
    Clone a Linode.

    linode_id
        The ID of the Linode to clone. Required.

    datacenter_id
        The ID of the Datacenter where the Linode will be placed. Required.

    plan_id
        The ID of the plan (size) of the Linode. Required.

    CLI Example:

    .. code-block:: bash

        salt-cloud -f clone my-linode-config linode_id=1234567 datacenter_id=2 plan_id=5
    '''
    if call == 'action':
        raise SaltCloudSystemExit(
            'The clone function must be called with -f or --function.'
        )

    if kwargs is None:
        kwargs = {}

    linode_id = kwargs.get('linode_id', None)
    datacenter_id = kwargs.get('datacenter_id', None)
    plan_id = kwargs.get('plan_id', None)
    required_params = [linode_id, datacenter_id, plan_id]

    for item in required_params:
        if item is None:
            raise SaltCloudSystemExit(
                'The clone function requires a \'linode_id\', \'datacenter_id\', '
                'and \'plan_id\' to be provided.'
            )

    clone_args = {
        'LinodeID': linode_id,
        'DatacenterID': datacenter_id,
        'PlanID': plan_id
    }

    return _query('linode', 'clone', args=clone_args)


def create(vm_):
    '''
    Create a single Linode VM.
    '''
    name = vm_['name']
    try:
        # Check for required profile parameters before sending any API calls.
        if vm_['profile'] and config.is_profile_configured(__opts__,
                                                           __active_provider_name__ or 'linode',
                                                           vm_['profile'],
                                                           vm_=vm_) is False:
            return False
    except AttributeError:
        pass

    if _validate_name(name) is False:
        return False

    __utils__['cloud.fire_event'](
        'event',
        'starting create',
        'salt/cloud/{0}/creating'.format(name),
        args=__utils__['cloud.filter_event']('creating', vm_, ['name', 'profile', 'provider', 'driver']),
        sock_dir=__opts__['sock_dir'],
        transport=__opts__['transport']
    )

    log.info('Creating Cloud VM %s', name)

    data = {}
    kwargs = {'name': name}

    plan_id = None
    size = vm_.get('size')
    if size:
        kwargs['size'] = size
        plan_id = get_plan_id(kwargs={'label': size})

    datacenter_id = None
    location = vm_.get('location')
    if location:
        try:
            datacenter_id = get_datacenter_id(location)
        except KeyError:
            # Linode's default datacenter is Dallas, but we still have to set one to
            # use the create function from Linode's API. Dallas's datacenter id is 2.
            datacenter_id = 2

    clonefrom_name = vm_.get('clonefrom')
    cloning = True if clonefrom_name else False
    if cloning:
        linode_id = get_linode_id_from_name(clonefrom_name)
        clone_source = get_linode(kwargs={'linode_id': linode_id})

        kwargs = {
            'clonefrom': clonefrom_name,
            'image': 'Clone of {0}'.format(clonefrom_name),
        }

        if size is None:
            size = clone_source['TOTALRAM']
            kwargs['size'] = size
            plan_id = clone_source['PLANID']

        if location is None:
            datacenter_id = clone_source['DATACENTERID']

        # Create new Linode from cloned Linode
        try:
            result = clone(kwargs={'linode_id': linode_id,
                                   'datacenter_id': datacenter_id,
                                   'plan_id': plan_id})
        except Exception as err:
            log.error(
                'Error cloning \'%s\' on Linode.\n\n'
                'The following exception was thrown by Linode when trying to '
                'clone the specified machine:\n%s',
                clonefrom_name, err, exc_info_on_loglevel=logging.DEBUG
            )
            return False
    else:
        kwargs['image'] = vm_['image']

        # Create Linode
        try:
            result = _query('linode', 'create', args={
                'PLANID': plan_id,
                'DATACENTERID': datacenter_id
            })
        except Exception as err:
            log.error(
                'Error creating %s on Linode\n\n'
                'The following exception was thrown by Linode when trying to '
                'run the initial deployment:\n%s',
                name, err, exc_info_on_loglevel=logging.DEBUG
            )
            return False

    if 'ERRORARRAY' in result:
        for error_data in result['ERRORARRAY']:
            log.error(
                'Error creating %s on Linode\n\n'
                'The Linode API returned the following: %s\n',
                name, error_data['ERRORMESSAGE']
            )
            return False

    __utils__['cloud.fire_event'](
        'event',
        'requesting instance',
        'salt/cloud/{0}/requesting'.format(name),
        args=__utils__['cloud.filter_event']('requesting', vm_, ['name', 'profile', 'provider', 'driver']),
        sock_dir=__opts__['sock_dir'],
        transport=__opts__['transport']
    )

    node_id = _clean_data(result)['LinodeID']
    data['id'] = node_id

    if not _wait_for_status(node_id, status=(_get_status_id_by_name('brand_new'))):
        log.error(
            'Error creating %s on LINODE\n\n'
            'while waiting for initial ready status',
            name, exc_info_on_loglevel=logging.DEBUG
        )

    # Update the Linode's Label to reflect the given VM name
    update_linode(node_id, update_args={'Label': name})
    log.debug('Set name for %s - was linode%s.', name, node_id)

    # Add private IP address if requested
    private_ip_assignment = get_private_ip(vm_)
    if private_ip_assignment:
        create_private_ip(node_id)

    # Define which ssh_interface to use
    ssh_interface = _get_ssh_interface(vm_)

    # If ssh_interface is set to use private_ips, but assign_private_ip
    # wasn't set to True, let's help out and create a private ip.
    if ssh_interface == 'private_ips' and private_ip_assignment is False:
        create_private_ip(node_id)
        private_ip_assignment = True

    if cloning:
        config_id = get_config_id(kwargs={'linode_id': node_id})['config_id']
    else:
        # Create disks and get ids
        log.debug('Creating disks for %s', name)
        root_disk_id = create_disk_from_distro(vm_, node_id)['DiskID']
        swap_disk_id = create_swap_disk(vm_, node_id)['DiskID']

        # Create a ConfigID using disk ids
        config_id = create_config(kwargs={'name': name,
                                          'linode_id': node_id,
                                          'root_disk_id': root_disk_id,
                                          'swap_disk_id': swap_disk_id})['ConfigID']

    # Boot the Linode
    boot(kwargs={'linode_id': node_id,
                 'config_id': config_id,
                 'check_running': False})

    node_data = get_linode(kwargs={'linode_id': node_id})
    ips = get_ips(node_id)
    state = int(node_data['STATUS'])

    data['image'] = kwargs['image']
    data['name'] = name
    data['size'] = size
    data['state'] = _get_status_descr_by_id(state)
    data['private_ips'] = ips['private_ips']
    data['public_ips'] = ips['public_ips']

    # Pass the correct IP address to the bootstrap ssh_host key
    if ssh_interface == 'private_ips':
        vm_['ssh_host'] = data['private_ips'][0]
    else:
        vm_['ssh_host'] = data['public_ips'][0]

    # If a password wasn't supplied in the profile or provider config, set it now.
    vm_['password'] = get_password(vm_)

    # Make public_ips and private_ips available to the bootstrap script.
    vm_['public_ips'] = ips['public_ips']
    vm_['private_ips'] = ips['private_ips']

    # Send event that the instance has booted.
    __utils__['cloud.fire_event'](
        'event',
        'waiting for ssh',
        'salt/cloud/{0}/waiting_for_ssh'.format(name),
        sock_dir=__opts__['sock_dir'],
        args={'ip_address': vm_['ssh_host']},
        transport=__opts__['transport']
    )

    # Bootstrap!
    ret = __utils__['cloud.bootstrap'](vm_, __opts__)

    ret.update(data)

    log.info('Created Cloud VM \'%s\'', name)
    log.debug('\'%s\' VM creation details:\n%s', name, pprint.pformat(data))

    __utils__['cloud.fire_event'](
        'event',
        'created instance',
        'salt/cloud/{0}/created'.format(name),
        args=__utils__['cloud.filter_event']('created', vm_, ['name', 'profile', 'provider', 'driver']),
        sock_dir=__opts__['sock_dir'],
        transport=__opts__['transport']
    )

    return ret


def create_config(kwargs=None, call=None):
    '''
    Creates a Linode Configuration Profile.

    name
        The name of the VM to create the config for.

    linode_id
        The ID of the Linode to create the configuration for.

    root_disk_id
        The Root Disk ID to be used for this config.

    swap_disk_id
        The Swap Disk ID to be used for this config.

    data_disk_id
        The Data Disk ID to be used for this config.

    .. versionadded:: 2016.3.0

    kernel_id
        The ID of the kernel to use for this configuration profile.
    '''
    if call == 'action':
        raise SaltCloudSystemExit(
            'The create_config function must be called with -f or --function.'
        )

    if kwargs is None:
        kwargs = {}

    name = kwargs.get('name', None)
    linode_id = kwargs.get('linode_id', None)
    root_disk_id = kwargs.get('root_disk_id', None)
    swap_disk_id = kwargs.get('swap_disk_id', None)
    data_disk_id = kwargs.get('data_disk_id', None)
    kernel_id = kwargs.get('kernel_id', None)

    if kernel_id is None:
        # 138 appears to always be the latest 64-bit kernel for Linux
        kernel_id = 138

    required_params = [name, linode_id, root_disk_id, swap_disk_id]
    for item in required_params:
        if item is None:
            raise SaltCloudSystemExit(
                'The create_config functions requires a \'name\', \'linode_id\', '
                '\'root_disk_id\', and \'swap_disk_id\'.'
            )

    disklist = '{0},{1}'.format(root_disk_id, swap_disk_id)
    if data_disk_id is not None:
        disklist = '{0},{1},{2}'.format(root_disk_id, swap_disk_id, data_disk_id)

    config_args = {'LinodeID': linode_id,
                   'KernelID': kernel_id,
                   'Label': name,
                   'DiskList': disklist
                  }

    result = _query('linode', 'config.create', args=config_args)

    return _clean_data(result)


def create_disk_from_distro(vm_, linode_id, swap_size=None):
    r'''
    Creates the disk for the Linode from the distribution.

    vm\_
        The VM profile to create the disk for.

    linode_id
        The ID of the Linode to create the distribution disk for. Required.

    swap_size
        The size of the disk, in MB.

    '''
    kwargs = {}

    if swap_size is None:
        swap_size = get_swap_size(vm_)

    pub_key = get_pub_key(vm_)
    root_password = get_password(vm_)

    if pub_key:
        kwargs.update({'rootSSHKey': pub_key})
    if root_password:
        kwargs.update({'rootPass': root_password})
    else:
        raise SaltCloudConfigError(
            'The Linode driver requires a password.'
        )

    kwargs.update({'LinodeID': linode_id,
                   'DistributionID': get_distribution_id(vm_),
                   'Label': vm_['name'],
                   'Size': get_disk_size(vm_, swap_size, linode_id)})

    result = _query('linode', 'disk.createfromdistribution', args=kwargs)

    return _clean_data(result)


def create_swap_disk(vm_, linode_id, swap_size=None):
    r'''
    Creates the disk for the specified Linode.

    vm\_
        The VM profile to create the swap disk for.

    linode_id
        The ID of the Linode to create the swap disk for.

    swap_size
        The size of the disk, in MB.
    '''
    kwargs = {}

    if not swap_size:
        swap_size = get_swap_size(vm_)

    kwargs.update({'LinodeID': linode_id,
                   'Label': vm_['name'],
                   'Type': 'swap',
                   'Size': swap_size
                  })

    result = _query('linode', 'disk.create', args=kwargs)

    return _clean_data(result)


def create_data_disk(vm_=None, linode_id=None, data_size=None):
    r'''
    Create a data disk for the linode (type is hardcoded to ext4 at the moment)

    .. versionadded:: 2016.3.0

    vm\_
        The VM profile to create the data disk for.

    linode_id
        The ID of the Linode to create the data disk for.

    data_size
        The size of the disk, in MB.

    '''
    kwargs = {}

    kwargs.update({'LinodeID': linode_id,
                   'Label': vm_['name']+"_data",
                   'Type': 'ext4',
                   'Size': data_size
                  })

    result = _query('linode', 'disk.create', args=kwargs)
    return _clean_data(result)


def create_private_ip(linode_id):
    r'''
    Creates a private IP for the specified Linode.

    linode_id
        The ID of the Linode to create the IP address for.
    '''
    kwargs = {'LinodeID': linode_id}
    result = _query('linode', 'ip.addprivate', args=kwargs)

    return _clean_data(result)


def destroy(name, call=None):
    '''
    Destroys a Linode by name.

    name
        The name of VM to be be destroyed.

    CLI Example:

    .. code-block:: bash

        salt-cloud -d vm_name
    '''
    if call == 'function':
        raise SaltCloudException(
            'The destroy action must be called with -d, --destroy, '
            '-a or --action.'
        )

    __utils__['cloud.fire_event'](
        'event',
        'destroying instance',
        'salt/cloud/{0}/destroying'.format(name),
        args={'name': name},
        sock_dir=__opts__['sock_dir'],
        transport=__opts__['transport']
    )

    linode_id = get_linode_id_from_name(name)

    response = _query('linode', 'delete', args={'LinodeID': linode_id, 'skipChecks': True})

    __utils__['cloud.fire_event'](
        'event',
        'destroyed instance',
        'salt/cloud/{0}/destroyed'.format(name),
        args={'name': name},
        sock_dir=__opts__['sock_dir'],
        transport=__opts__['transport']
    )

    if __opts__.get('update_cachedir', False) is True:
        __utils__['cloud.delete_minion_cachedir'](name, __active_provider_name__.split(':')[0], __opts__)

    return response


def get_config_id(kwargs=None, call=None):
    '''
    Returns a config_id for a given linode.

    .. versionadded:: 2015.8.0

    name
        The name of the Linode for which to get the config_id. Can be used instead
        of ``linode_id``.h

    linode_id
        The ID of the Linode for which to get the config_id. Can be used instead
        of ``name``.

    CLI Example:

    .. code-block:: bash

        salt-cloud -f get_config_id my-linode-config name=my-linode
        salt-cloud -f get_config_id my-linode-config linode_id=1234567
    '''
    if call == 'action':
        raise SaltCloudException(
            'The get_config_id function must be called with -f or --function.'
        )

    if kwargs is None:
        kwargs = {}

    name = kwargs.get('name', None)
    linode_id = kwargs.get('linode_id', None)
    if name is None and linode_id is None:
        raise SaltCloudSystemExit(
            'The get_config_id function requires either a \'name\' or a \'linode_id\' '
            'to be provided.'
        )
    if linode_id is None:
        linode_id = get_linode_id_from_name(name)

    response = _query('linode', 'config.list', args={'LinodeID': linode_id})['DATA']
    config_id = {'config_id': response[0]['ConfigID']}

    return config_id


def get_datacenter_id(location):
    '''
    Returns the Linode Datacenter ID.

    location
        The location, or name, of the datacenter to get the ID from.
    '''

    return avail_locations()[location]['DATACENTERID']


def get_disk_size(vm_, swap, linode_id):
    r'''
    Returns the size of of the root disk in MB.

    vm\_
        The VM to get the disk size for.
    '''
    disk_size = get_linode(kwargs={'linode_id': linode_id})['TOTALHD']
    return config.get_cloud_config_value(
        'disk_size', vm_, __opts__, default=disk_size - swap
    )


def get_data_disk_size(vm_, swap, linode_id):
    '''
    Return the size of of the data disk in MB

    .. versionadded:: 2016.3.0
    '''
    disk_size = get_linode(kwargs={'linode_id': linode_id})['TOTALHD']
    root_disk_size = config.get_cloud_config_value(
        'disk_size', vm_, __opts__, default=disk_size - swap
    )
    return disk_size - root_disk_size - swap


def get_distribution_id(vm_):
    r'''
    Returns the distribution ID for a VM

    vm\_
        The VM to get the distribution ID for
    '''
    distributions = _query('avail', 'distributions')['DATA']
    vm_image_name = config.get_cloud_config_value('image', vm_, __opts__)

    distro_id = ''

    for distro in distributions:
        if vm_image_name == distro['LABEL']:
            distro_id = distro['DISTRIBUTIONID']
            return distro_id

    if not distro_id:
        raise SaltCloudNotFound(
            'The DistributionID for the \'{0}\' profile could not be found.\n'
            'The \'{1}\' instance could not be provisioned. The following distributions '
            'are available:\n{2}'.format(
                vm_image_name,
                vm_['name'],
                pprint.pprint(sorted([distro['LABEL'].encode(__salt_system_encoding__) for distro in distributions]))
            )
        )


def get_ips(linode_id=None):
    '''
    Returns public and private IP addresses.

    linode_id
        Limits the IP addresses returned to the specified Linode ID.
    '''
    if linode_id:
        ips = _query('linode', 'ip.list', args={'LinodeID': linode_id})
    else:
        ips = _query('linode', 'ip.list')

    ips = ips['DATA']
    ret = {}

    for item in ips:
        node_id = six.text_type(item['LINODEID'])
        if item['ISPUBLIC'] == 1:
            key = 'public_ips'
        else:
            key = 'private_ips'

        if ret.get(node_id) is None:
            ret.update({node_id: {'public_ips': [], 'private_ips': []}})
        ret[node_id][key].append(item['IPADDRESS'])

    # If linode_id was specified, only return the ips, and not the
    # dictionary based on the linode ID as a key.
    if linode_id:
        _all_ips = {'public_ips': [], 'private_ips': []}
        matching_id = ret.get(six.text_type(linode_id))
        if matching_id:
            _all_ips['private_ips'] = matching_id['private_ips']
            _all_ips['public_ips'] = matching_id['public_ips']

        ret = _all_ips

    return ret


def get_linode(kwargs=None, call=None):
    '''
    Returns data for a single named Linode.

    name
        The name of the Linode for which to get data. Can be used instead
        ``linode_id``. Note this will induce an additional API call
        compared to using ``linode_id``.

    linode_id
        The ID of the Linode for which to get data. Can be used instead of
        ``name``.

    CLI Example:

    .. code-block:: bash

        salt-cloud -f get_linode my-linode-config name=my-instance
        salt-cloud -f get_linode my-linode-config linode_id=1234567
    '''
    if call == 'action':
        raise SaltCloudSystemExit(
            'The get_linode function must be called with -f or --function.'
        )

    if kwargs is None:
        kwargs = {}

    name = kwargs.get('name', None)
    linode_id = kwargs.get('linode_id', None)
    if name is None and linode_id is None:
        raise SaltCloudSystemExit(
            'The get_linode function requires either a \'name\' or a \'linode_id\'.'
        )

    if linode_id is None:
        linode_id = get_linode_id_from_name(name)

    result = _query('linode', 'list', args={'LinodeID': linode_id})

    return result['DATA'][0]


def get_linode_id_from_name(name):
    '''
    Returns the Linode ID for a VM from the provided name.

    name
        The name of the Linode from which to get the Linode ID. Required.
    '''
    nodes = _query('linode', 'list')['DATA']

    linode_id = ''
    for node in nodes:
        if name == node['LABEL']:
            linode_id = node['LINODEID']
            return linode_id

    if not linode_id:
        raise SaltCloudNotFound(
            'The specified name, {0}, could not be found.'.format(name)
        )


def get_password(vm_):
    r'''
    Return the password to use for a VM.

    vm\_
        The configuration to obtain the password from.
    '''
    return config.get_cloud_config_value(
        'password', vm_, __opts__,
        default=config.get_cloud_config_value(
            'passwd', vm_, __opts__,
            search_global=False
        ),
        search_global=False
    )


def _decode_linode_plan_label(label):
    '''
    Attempts to decode a user-supplied Linode plan label
    into the format in Linode API output

    label
        The label, or name, of the plan to decode.

    Example:
        `Linode 2048` will decode to `Linode 2GB`
    '''
    sizes = avail_sizes()

    if label not in sizes:
        if 'GB' in label:
            raise SaltCloudException(
                'Invalid Linode plan ({}) specified - call avail_sizes() for all available options'.format(label)
            )
        else:
            plan = label.split()

            if len(plan) != 2:
                raise SaltCloudException(
                    'Invalid Linode plan ({}) specified - call avail_sizes() for all available options'.format(label)
                )

            plan_type = plan[0]
            try:
                plan_size = int(plan[1])
            except TypeError:
                plan_size = 0
                log.debug('Failed to decode Linode plan label in Cloud Profile: %s', label)

            if plan_type == 'Linode' and plan_size == 1024:
                plan_type = 'Nanode'

            plan_size = plan_size/1024
            new_label = "{} {}GB".format(plan_type, plan_size)

            if new_label not in sizes:
                raise SaltCloudException(
                    'Invalid Linode plan ({}) specified - call avail_sizes() for all available options'.format(new_label)
                )

            log.warning(
                'An outdated Linode plan label was detected in your Cloud '
                'Profile (%s). Please update the profile to use the new '
                'label format (%s) for the requested Linode plan size.',
                label, new_label
            )

            label = new_label

    return sizes[label]['PLANID']


def get_plan_id(kwargs=None, call=None):
    '''
    Returns the Linode Plan ID.

    label
        The label, or name, of the plan to get the ID from.

    CLI Example:

    .. code-block:: bash

        salt-cloud -f get_plan_id linode label="Linode 1024"
    '''
    if call == 'action':
        raise SaltCloudException(
            'The show_instance action must be called with -f or --function.'
        )

    if kwargs is None:
        kwargs = {}

    label = kwargs.get('label', None)
    if label is None:
        raise SaltCloudException(
            'The get_plan_id function requires a \'label\'.'
        )

    label = _decode_linode_plan_label(label)

    return label


def get_private_ip(vm_):
    '''
    Return True if a private ip address is requested
    '''
    return config.get_cloud_config_value(
        'assign_private_ip', vm_, __opts__, default=False
    )


def get_data_disk(vm_):
    '''
    Return True if a data disk is requested

    .. versionadded:: 2016.3.0
    '''
    return config.get_cloud_config_value(
        'allocate_data_disk', vm_, __opts__, default=False
    )


def get_pub_key(vm_):
    r'''
    Return the SSH pubkey.

    vm\_
        The configuration to obtain the public key from.
    '''
    return config.get_cloud_config_value(
        'ssh_pubkey', vm_, __opts__, search_global=False
    )


def get_swap_size(vm_):
    r'''
    Returns the amoutn of swap space to be used in MB.

    vm\_
        The VM profile to obtain the swap size from.
    '''
    return config.get_cloud_config_value(
        'swap', vm_, __opts__, default=128
    )


def get_vm_size(vm_):
    r'''
    Returns the VM's size.

    vm\_
        The VM to get the size for.
    '''
    vm_size = config.get_cloud_config_value('size', vm_, __opts__)
    ram = avail_sizes()[vm_size]['RAM']

    if vm_size.startswith('Linode'):
        vm_size = vm_size.replace('Linode ', '')

    if ram == int(vm_size):
        return ram
    else:
        raise SaltCloudNotFound(
            'The specified size, {0}, could not be found.'.format(vm_size)
        )


def list_nodes(call=None):
    '''
    Returns a list of linodes, keeping only a brief listing.

    CLI Example:

    .. code-block:: bash

        salt-cloud -Q
        salt-cloud --query
        salt-cloud -f list_nodes my-linode-config

    .. note::

        The ``image`` label only displays information about the VM's distribution vendor,
        such as "Debian" or "RHEL" and does not display the actual image name. This is
        due to a limitation of the Linode API.
    '''
    if call == 'action':
        raise SaltCloudException(
            'The list_nodes function must be called with -f or --function.'
        )
    return _list_linodes(full=False)


def list_nodes_full(call=None):
    '''
    List linodes, with all available information.

    CLI Example:

    .. code-block:: bash

        salt-cloud -F
        salt-cloud --full-query
        salt-cloud -f list_nodes_full my-linode-config

    .. note::

        The ``image`` label only displays information about the VM's distribution vendor,
        such as "Debian" or "RHEL" and does not display the actual image name. This is
        due to a limitation of the Linode API.
    '''
    if call == 'action':
        raise SaltCloudException(
            'The list_nodes_full function must be called with -f or --function.'
        )
    return _list_linodes(full=True)


def list_nodes_min(call=None):
    '''
    Return a list of the VMs that are on the provider. Only a list of VM names and
    their state is returned. This is the minimum amount of information needed to
    check for existing VMs.

    .. versionadded:: 2015.8.0

    CLI Example:

    .. code-block:: bash

        salt-cloud -f list_nodes_min my-linode-config
        salt-cloud --function list_nodes_min my-linode-config
    '''
    if call == 'action':
        raise SaltCloudSystemExit(
            'The list_nodes_min function must be called with -f or --function.'
        )

    ret = {}
    nodes = _query('linode', 'list')['DATA']

    for node in nodes:
        name = node['LABEL']
        this_node = {
            'id': six.text_type(node['LINODEID']),
            'state': _get_status_descr_by_id(int(node['STATUS']))
        }

        ret[name] = this_node

    return ret


def list_nodes_select(call=None):
    '''
    Return a list of the VMs that are on the provider, with select fields.
    '''
    return __utils__['cloud.list_nodes_select'](
        list_nodes_full(), __opts__['query.selection'], call,
    )


def reboot(name, call=None):
    '''
    Reboot a linode.

    .. versionadded:: 2015.8.0

    name
        The name of the VM to reboot.

    CLI Example:

    .. code-block:: bash

        salt-cloud -a reboot vm_name
    '''
    if call != 'action':
        raise SaltCloudException(
            'The show_instance action must be called with -a or --action.'
        )

    node_id = get_linode_id_from_name(name)
    response = _query('linode', 'reboot', args={'LinodeID': node_id})
    data = _clean_data(response)
    reboot_jid = data['JobID']

    if not _wait_for_job(node_id, reboot_jid):
        log.error('Reboot failed for %s.', name)
        return False

    return data


def show_instance(name, call=None):
    '''
    Displays details about a particular Linode VM. Either a name or a linode_id must
    be provided.

    .. versionadded:: 2015.8.0

    name
        The name of the VM for which to display details.

    CLI Example:

    .. code-block:: bash

        salt-cloud -a show_instance vm_name

    .. note::

        The ``image`` label only displays information about the VM's distribution vendor,
        such as "Debian" or "RHEL" and does not display the actual image name. This is
        due to a limitation of the Linode API.
    '''
    if call != 'action':
        raise SaltCloudException(
            'The show_instance action must be called with -a or --action.'
        )

    node_id = get_linode_id_from_name(name)
    node_data = get_linode(kwargs={'linode_id': node_id})
    ips = get_ips(node_id)
    state = int(node_data['STATUS'])

    ret = {'id': node_data['LINODEID'],
           'image': node_data['DISTRIBUTIONVENDOR'],
           'name': node_data['LABEL'],
           'size': node_data['TOTALRAM'],
           'state': _get_status_descr_by_id(state),
           'private_ips': ips['private_ips'],
           'public_ips': ips['public_ips']}

    return ret


def show_pricing(kwargs=None, call=None):
    '''
    Show pricing for a particular profile. This is only an estimate, based on
    unofficial pricing sources.

    .. versionadded:: 2015.8.0

    CLI Example:

    .. code-block:: bash

        salt-cloud -f show_pricing my-linode-config profile=my-linode-profile
    '''
    if call != 'function':
        raise SaltCloudException(
            'The show_instance action must be called with -f or --function.'
        )

    profile = __opts__['profiles'].get(kwargs['profile'], {})
    if not profile:
        raise SaltCloudNotFound(
            'The requested profile was not found.'
        )

    # Make sure the profile belongs to Linode
    provider = profile.get('provider', '0:0')
    comps = provider.split(':')
    if len(comps) < 2 or comps[1] != 'linode':
        raise SaltCloudException(
            'The requested profile does not belong to Linode.'
        )

    plan_id = get_plan_id(kwargs={'label': profile['size']})
    response = _query('avail', 'linodeplans', args={'PlanID': plan_id})['DATA'][0]

    ret = {}
    ret['per_hour'] = response['HOURLY']
    ret['per_day'] = ret['per_hour'] * 24
    ret['per_week'] = ret['per_day'] * 7
    ret['per_month'] = response['PRICE']
    ret['per_year'] = ret['per_month'] * 12

    return {profile['profile']: ret}


def start(name, call=None):
    '''
    Start a VM in Linode.

    name
        The name of the VM to start.

    CLI Example:

    .. code-block:: bash

        salt-cloud -a stop vm_name
    '''
    if call != 'action':
        raise SaltCloudException(
            'The start action must be called with -a or --action.'
        )

    node_id = get_linode_id_from_name(name)
    node = get_linode(kwargs={'linode_id': node_id})

    if node['STATUS'] == 1:
        return {'success': True,
                'action': 'start',
                'state': 'Running',
                'msg': 'Machine already running'}

    response = _query('linode', 'boot', args={'LinodeID': node_id})['DATA']

    if _wait_for_job(node_id, response['JobID']):
        return {'state': 'Running',
                'action': 'start',
                'success': True}
    else:
        return {'action': 'start',
                'success': False}


def stop(name, call=None):
    '''
    Stop a VM in Linode.

    name
        The name of the VM to stop.

    CLI Example:

    .. code-block:: bash

        salt-cloud -a stop vm_name
    '''
    if call != 'action':
        raise SaltCloudException(
            'The stop action must be called with -a or --action.'
        )

    node_id = get_linode_id_from_name(name)
    node = get_linode(kwargs={'linode_id': node_id})

    if node['STATUS'] == 2:
        return {'success': True,
                'state': 'Stopped',
                'msg': 'Machine already stopped'}

    response = _query('linode', 'shutdown', args={'LinodeID': node_id})['DATA']

    if _wait_for_job(node_id, response['JobID']):
        return {'state': 'Stopped',
                'action': 'stop',
                'success': True}
    else:
        return {'action': 'stop',
                'success': False}


def update_linode(linode_id, update_args=None):
    '''
    Updates a Linode's properties.

    linode_id
        The ID of the Linode to shutdown. Required.

    update_args
        The args to update the Linode with. Must be in dictionary form.
    '''
    update_args.update({'LinodeID': linode_id})

    result = _query('linode', 'update', args=update_args)

    return _clean_data(result)


def _clean_data(api_response):
    '''
    Returns the DATA response from a Linode API query as a single pre-formatted dictionary

    api_response
        The query to be cleaned.
    '''
    data = {}
    data.update(api_response['DATA'])

    if not data:
        response_data = api_response['DATA']
        data.update(response_data)

    return data


def _list_linodes(full=False):
    '''
    Helper function to format and parse linode data
    '''
    nodes = _query('linode', 'list')['DATA']
    ips = get_ips()

    ret = {}
    for node in nodes:
        this_node = {}
        linode_id = six.text_type(node['LINODEID'])

        this_node['id'] = linode_id
        this_node['image'] = node['DISTRIBUTIONVENDOR']
        this_node['name'] = node['LABEL']
        this_node['size'] = node['TOTALRAM']

        state = int(node['STATUS'])
        this_node['state'] = _get_status_descr_by_id(state)

        for key, val in six.iteritems(ips):
            if key == linode_id:
                this_node['private_ips'] = val['private_ips']
                this_node['public_ips'] = val['public_ips']

        if full:
            this_node['extra'] = node

        ret[node['LABEL']] = this_node

    return ret


def _query(action=None,
           command=None,
           args=None,
           method='GET',
           header_dict=None,
           data=None,
           url='https://api.linode.com/'):
    '''
    Make a web call to the Linode API.
    '''
    global LASTCALL
    vm_ = get_configured_provider()

    ratelimit_sleep = config.get_cloud_config_value(
        'ratelimit_sleep', vm_, __opts__, search_global=False, default=0,
    )
    apikey = config.get_cloud_config_value(
        'apikey', vm_, __opts__, search_global=False
    )

    if not isinstance(args, dict):
        args = {}

    if 'api_key' not in args.keys():
        args['api_key'] = apikey

    if action and 'api_action' not in args.keys():
        args['api_action'] = '{0}.{1}'.format(action, command)

    if header_dict is None:
        header_dict = {}

    if method != 'POST':
        header_dict['Accept'] = 'application/json'

    decode = True
    if method == 'DELETE':
        decode = False

    now = int(time.mktime(datetime.datetime.now().timetuple()))
    if LASTCALL >= now:
        time.sleep(ratelimit_sleep)

    result = __utils__['http.query'](
        url,
        method,
        params=args,
        data=data,
        header_dict=header_dict,
        decode=decode,
        decode_type='json',
        text=True,
        status=True,
        hide_fields=['api_key', 'rootPass'],
        opts=__opts__,
    )

    if 'ERRORARRAY' in result['dict']:
        if result['dict']['ERRORARRAY']:
            error_list = []

            for error in result['dict']['ERRORARRAY']:
                msg = error['ERRORMESSAGE']

                if msg == "Authentication failed":
                    raise SaltCloudSystemExit(
                        'Linode API Key is expired or invalid'
                    )
                else:
                    error_list.append(msg)
            raise SaltCloudException(
                'Linode API reported error(s): {}'.format(", ".join(error_list))
            )

    LASTCALL = int(time.mktime(datetime.datetime.now().timetuple()))
    log.debug('Linode Response Status Code: %s', result['status'])

    return result['dict']


def _wait_for_job(linode_id, job_id, timeout=300, quiet=True):
    '''
    Wait for a Job to return.

    linode_id
        The ID of the Linode to wait on. Required.

    job_id
        The ID of the job to wait for.

    timeout
        The amount of time to wait for a status to update.

    quiet
        Log status updates to debug logs when True. Otherwise, logs to info.
    '''
    interval = 5
    iterations = int(timeout / interval)

    for i in range(0, iterations):
        jobs_result = _query('linode',
                             'job.list',
                             args={'LinodeID': linode_id})['DATA']
        if jobs_result[0]['JOBID'] == job_id and jobs_result[0]['HOST_SUCCESS'] == 1:
            return True

        time.sleep(interval)
        log.log(
            logging.INFO if not quiet else logging.DEBUG,
            'Still waiting on Job %s for Linode %s.', job_id, linode_id
        )
    return False


def _wait_for_status(linode_id, status=None, timeout=300, quiet=True):
    '''
    Wait for a certain status from Linode.

    linode_id
        The ID of the Linode to wait on. Required.

    status
        The status to look for to update.

    timeout
        The amount of time to wait for a status to update.

    quiet
        Log status updates to debug logs when False. Otherwise, logs to info.
    '''
    if status is None:
        status = _get_status_id_by_name('brand_new')

    status_desc_waiting = _get_status_descr_by_id(status)

    interval = 5
    iterations = int(timeout / interval)

    for i in range(0, iterations):
        result = get_linode(kwargs={'linode_id': linode_id})

        if result['STATUS'] == status:
            return True

        status_desc_result = _get_status_descr_by_id(result['STATUS'])

        time.sleep(interval)
        log.log(
            logging.INFO if not quiet else logging.DEBUG,
            'Status for Linode %s is \'%s\', waiting for \'%s\'.',
            linode_id, status_desc_result, status_desc_waiting
        )

    return False


def _get_status_descr_by_id(status_id):
    '''
    Return linode status by ID

    status_id
        linode VM status ID
    '''
    for status_name, status_data in six.iteritems(LINODE_STATUS):
        if status_data['code'] == int(status_id):
            return status_data['descr']
    return LINODE_STATUS.get(status_id, None)


def _get_status_id_by_name(status_name):
    '''
    Return linode status description by internalstatus name

    status_name
        internal linode VM status name
    '''
    return LINODE_STATUS.get(status_name, {}).get('code', None)


def _validate_name(name):
    '''
    Checks if the provided name fits Linode's labeling parameters.

    .. versionadded:: 2015.5.6

    name
        The VM name to validate
    '''
    name = six.text_type(name)
    name_length = len(name)
    regex = re.compile(r'^[a-zA-Z0-9][A-Za-z0-9_-]*[a-zA-Z0-9]$')

    if name_length < 3 or name_length > 48:
        ret = False
    elif not re.match(regex, name):
        ret = False
    else:
        ret = True

    if ret is False:
        log.warning(
            'A Linode label may only contain ASCII letters or numbers, dashes, and '
            'underscores, must begin and end with letters or numbers, and be at least '
            'three characters in length.'
        )

    return ret


def _get_ssh_interface(vm_):
    '''
    Return the ssh_interface type to connect to. Either 'public_ips' (default)
    or 'private_ips'.
    '''
    return config.get_cloud_config_value(
        'ssh_interface', vm_, __opts__, default='public_ips',
        search_global=False
    )