# coding=utf-8

# Copyright 2013 International Business Machines Corporation
# All Rights Reserved.
#
#    Licensed 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.

"""
Ironic Native IPMI power manager.
"""

import os
import tempfile

from oslo_config import cfg
from oslo_utils import excutils
from oslo_utils import importutils

from ironic.common import boot_devices
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common.i18n import _LE
from ironic.common.i18n import _LW
from ironic.common import states
from ironic.common import utils
from ironic.conductor import task_manager
from ironic.drivers import base
from ironic.drivers.modules import console_utils
from ironic.openstack.common import log as logging

pyghmi = importutils.try_import('pyghmi')
if pyghmi:
    from pyghmi import exceptions as pyghmi_exception
    from pyghmi.ipmi import command as ipmi_command

opts = [
    cfg.IntOpt('retry_timeout',
               default=60,
               help='Maximum time in seconds to retry IPMI operations. There '
                    'is a tradeoff when setting this value. Setting this too '
                    'low may cause older BMCs to crash and require a hard '
                    'reset. However, setting too high can cause the sync '
                    'power state periodic task to hang when there are slow '
                    'or unresponsive BMCs.'),
    cfg.IntOpt('min_command_interval',
               default=5,
               help='Minimum time, in seconds, between IPMI operations '
                    'sent to a server. There is a risk with some hardware '
                    'that setting this too low may cause the BMC to crash. '
                    'Recommended setting is 5 seconds.'),
    ]

CONF = cfg.CONF
CONF.register_opts(opts, group='ipmi')

LOG = logging.getLogger(__name__)

REQUIRED_PROPERTIES = {'ipmi_address': _("IP of the node's BMC. Required."),
                       'ipmi_password': _("IPMI password. Required."),
                       'ipmi_username': _("IPMI username. Required.")}
COMMON_PROPERTIES = REQUIRED_PROPERTIES
CONSOLE_PROPERTIES = {
    'ipmi_terminal_port': _("node's UDP port to connect to. Only required for "
                            "console access.")
}

_BOOT_DEVICES_MAP = {
    boot_devices.DISK: 'hd',
    boot_devices.PXE: 'network',
    boot_devices.CDROM: 'cdrom',
    boot_devices.BIOS: 'setup',
}


def _parse_driver_info(node):
    """Gets the bmc access info for the given node.

    :raises: MissingParameterValue when required ipmi credentials
            are missing.
    :raises: InvalidParameterValue when the IPMI terminal port is not an
            integer.
    """

    info = node.driver_info or {}
    missing_info = [key for key in REQUIRED_PROPERTIES if not info.get(key)]
    if missing_info:
        raise exception.MissingParameterValue(_(
            "Missing the following IPMI credentials in node's"
            " driver_info: %s.") % missing_info)

    bmc_info = {}
    bmc_info['address'] = info.get('ipmi_address')
    bmc_info['username'] = info.get('ipmi_username')
    bmc_info['password'] = info.get('ipmi_password')

    # get additional info
    bmc_info['uuid'] = node.uuid

    # terminal port must be an integer
    port = info.get('ipmi_terminal_port')
    if port is not None:
        try:
            port = int(port)
        except ValueError:
            raise exception.InvalidParameterValue(_(
                "IPMI terminal port is not an integer."))
    bmc_info['port'] = port

    return bmc_info


def _console_pwfile_path(uuid):
    """Return the file path for storing the ipmi password."""
    file_name = "%(uuid)s.pw" % {'uuid': uuid}
    return os.path.join(tempfile.gettempdir(), file_name)


def _power_on(driver_info):
    """Turn the power on for this node.

    :param driver_info: the bmc access info for a node.
    :returns: power state POWER_ON, one of :class:`ironic.common.states`.
    :raises: IPMIFailure when the native ipmi call fails.
    :raises: PowerStateFailure when invalid power state is returned
             from ipmi.
    """

    msg = _LW("IPMI power on failed for node %(node_id)s with the "
              "following error: %(error)s")
    try:
        ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
                           userid=driver_info['username'],
                           password=driver_info['password'])
        wait = CONF.ipmi.retry_timeout
        ret = ipmicmd.set_power('on', wait)
    except pyghmi_exception.IpmiException as e:
        LOG.warning(msg, {'node_id': driver_info['uuid'], 'error': str(e)})
        raise exception.IPMIFailure(cmd=str(e))

    state = ret.get('powerstate')
    if state == 'on':
        return states.POWER_ON
    else:
        LOG.warning(msg, {'node_id': driver_info['uuid'], 'error': ret})
        raise exception.PowerStateFailure(pstate=state)


def _power_off(driver_info):
    """Turn the power off for this node.

    :param driver_info: the bmc access info for a node.
    :returns: power state POWER_OFF, one of :class:`ironic.common.states`.
    :raises: IPMIFailure when the native ipmi call fails.
    :raises: PowerStateFailure when invalid power state is returned
             from ipmi.
    """

    msg = _LW("IPMI power off failed for node %(node_id)s with the "
              "following error: %(error)s")
    try:
        ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
                           userid=driver_info['username'],
                           password=driver_info['password'])
        wait = CONF.ipmi.retry_timeout
        ret = ipmicmd.set_power('off', wait)
    except pyghmi_exception.IpmiException as e:
        LOG.warning(msg, {'node_id': driver_info['uuid'], 'error': str(e)})
        raise exception.IPMIFailure(cmd=str(e))

    state = ret.get('powerstate')
    if state == 'off':
        return states.POWER_OFF
    else:
        LOG.warning(msg % {'node_id': driver_info['uuid'], 'error': ret})
        raise exception.PowerStateFailure(pstate=state)


def _reboot(driver_info):
    """Reboot this node.

    If the power is off, turn it on. If the power is on, reset it.

    :param driver_info: the bmc access info for a node.
    :returns: power state POWER_ON, one of :class:`ironic.common.states`.
    :raises: IPMIFailure when the native ipmi call fails.
    :raises: PowerStateFailure when invalid power state is returned
             from ipmi.
    """

    msg = _LW("IPMI power reboot failed for node %(node_id)s with the "
              "following error: %(error)s")
    try:
        ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
                           userid=driver_info['username'],
                           password=driver_info['password'])
        wait = CONF.ipmi.retry_timeout
        ret = ipmicmd.set_power('boot', wait)
    except pyghmi_exception.IpmiException as e:
        LOG.warning(msg % {'node_id': driver_info['uuid'], 'error': str(e)})
        raise exception.IPMIFailure(cmd=str(e))

    state = ret.get('powerstate')
    if state == 'on':
        return states.POWER_ON
    else:
        LOG.warning(msg % {'node_id': driver_info['uuid'], 'error': ret})
        raise exception.PowerStateFailure(pstate=state)


def _power_status(driver_info):
    """Get the power status for this node.

    :param driver_info: the bmc access info for a node.
    :returns: power state POWER_ON, POWER_OFF or ERROR defined in
             :class:`ironic.common.states`.
    :raises: IPMIFailure when the native ipmi call fails.
    """

    try:
        ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
                           userid=driver_info['username'],
                           password=driver_info['password'])
        ret = ipmicmd.get_power()
    except pyghmi_exception.IpmiException as e:
        LOG.warning(_LW("IPMI get power state failed for node %(node_id)s "
                        "with the following error: %(error)s"),
                    {'node_id': driver_info['uuid'], 'error': str(e)})
        raise exception.IPMIFailure(cmd=str(e))

    state = ret.get('powerstate')
    if state == 'on':
        return states.POWER_ON
    elif state == 'off':
        return states.POWER_OFF
    else:
        # NOTE(linggao): Do not throw an exception here because it might
        # return other valid values. It is up to the caller to decide
        # what to do.
        LOG.warning(_LW("IPMI get power state for node %(node_id)s returns the"
                        " following details: %(detail)s"),
                    {'node_id': driver_info['uuid'], 'detail': ret})
        return states.ERROR


def _get_sensors_data(driver_info):
    """Get sensors data.

    :param driver_info: node's driver info
    :raises: FailedToGetSensorData when getting the sensor data fails.
    :returns: returns a dict of sensor data group by sensor type.
    """
    try:
        ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
            userid=driver_info['username'],
            password=driver_info['password'])
        ret = ipmicmd.get_sensor_data()
    except Exception as e:
        LOG.error(_LE("IPMI get sensor data failed for node %(node_id)s "
                  "with the following error: %(error)s"),
              {'node_id': driver_info['uuid'], 'error': e})
        raise exception.FailedToGetSensorData(
                    node=driver_info['uuid'], error=e)

    if not ret:
        return {}

    sensors_data = {}
    for reading in ret:
        # ignore the sensor data which has no sensor reading value
        if not reading.value:
            continue
        sensors_data.setdefault(reading.type,
            {})[reading.name] = {
              'Sensor Reading': '%s %s' % (reading.value, reading.units),
              'Sensor ID': reading.name,
              'States': str(reading.states),
              'Units': reading.units,
              'Health': str(reading.health)}

    return sensors_data


class NativeIPMIPower(base.PowerInterface):
    """The power driver using native python-ipmi library."""

    def get_properties(self):
        return COMMON_PROPERTIES

    def validate(self, task):
        """Check that node['driver_info'] contains IPMI credentials.

        :param task: a TaskManager instance containing the node to act on.
        :raises: MissingParameterValue when required ipmi credentials
                 are missing.
        """
        _parse_driver_info(task.node)

    def get_power_state(self, task):
        """Get the current power state of the task's node.

        :param task: a TaskManager instance containing the node to act on.
        :returns:  power state POWER_ON, POWER_OFF or ERROR defined in
                 :class:`ironic.common.states`.
        :raises: MissingParameterValue when required ipmi credentials
                 are missing.
        :raises: IPMIFailure when the native ipmi call fails.
        """
        driver_info = _parse_driver_info(task.node)
        return _power_status(driver_info)

    @task_manager.require_exclusive_lock
    def set_power_state(self, task, pstate):
        """Turn the power on or off.

        :param task: a TaskManager instance containing the node to act on.
        :param pstate: a power state that will be set on the task's node.
        :raises: IPMIFailure when the native ipmi call fails.
        :raises: MissingParameterValue when required ipmi credentials
                 are missing.
        :raises: InvalidParameterValue when an invalid power state
                 is specified
        :raises: PowerStateFailure when invalid power state is returned
                 from ipmi.
        """

        driver_info = _parse_driver_info(task.node)

        if pstate == states.POWER_ON:
            _power_on(driver_info)
        elif pstate == states.POWER_OFF:
            _power_off(driver_info)
        else:
            raise exception.InvalidParameterValue(_(
                "set_power_state called with an invalid power state: %s."
                ) % pstate)

    @task_manager.require_exclusive_lock
    def reboot(self, task):
        """Cycles the power to the task's node.

        :param task: a TaskManager instance containing the node to act on.
        :raises: IPMIFailure when the native ipmi call fails.
        :raises: MissingParameterValue when required ipmi credentials
                 are missing.
        :raises: PowerStateFailure when invalid power state is returned
                 from ipmi.
        """

        driver_info = _parse_driver_info(task.node)
        _reboot(driver_info)


class NativeIPMIManagement(base.ManagementInterface):

    def get_properties(self):
        return COMMON_PROPERTIES

    def validate(self, task):
        """Check that 'driver_info' contains IPMI credentials.

        Validates whether the 'driver_info' property of the supplied
        task's node contains the required credentials information.

        :param task: a task from TaskManager.
        :raises: MissingParameterValue when required ipmi credentials
                 are missing.

        """
        _parse_driver_info(task.node)

    def get_supported_boot_devices(self):
        """Get a list of the supported boot devices.

        :returns: A list with the supported boot devices defined
                  in :mod:`ironic.common.boot_devices`.

        """
        return list(_BOOT_DEVICES_MAP.keys())

    @task_manager.require_exclusive_lock
    def set_boot_device(self, task, device, persistent=False):
        """Set the boot device for the task's node.

        Set the boot device to use on next reboot of the node.

        :param task: a task from TaskManager.
        :param device: the boot device, one of
                       :mod:`ironic.common.boot_devices`.
        :param persistent: Boolean value. True if the boot device will
                           persist to all future boots, False if not.
                           Default: False.
        :raises: InvalidParameterValue if an invalid boot device is specified
                 or required ipmi credentials are missing.
        :raises: MissingParameterValue when required ipmi credentials
                 are missing.
        :raises: IPMIFailure on an error from pyghmi.
        """
        if device not in self.get_supported_boot_devices():
            raise exception.InvalidParameterValue(_(
                "Invalid boot device %s specified.") % device)
        driver_info = _parse_driver_info(task.node)
        try:
            ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
                               userid=driver_info['username'],
                               password=driver_info['password'])
            bootdev = _BOOT_DEVICES_MAP[device]
            ipmicmd.set_bootdev(bootdev, persist=persistent)
        except pyghmi_exception.IpmiException as e:
            LOG.error(_LE("IPMI set boot device failed for node %(node_id)s "
                          "with the following error: %(error)s"),
                      {'node_id': driver_info['uuid'], 'error': e})
            raise exception.IPMIFailure(cmd=e)

    def get_boot_device(self, task):
        """Get the current boot device for the task's node.

        Returns the current boot device of the node.

        :param task: a task from TaskManager.
        :raises: MissingParameterValue if required IPMI parameters
            are missing.
        :raises: IPMIFailure on an error from pyghmi.
        :returns: a dictionary containing:

            :boot_device: the boot device, one of
                :mod:`ironic.common.boot_devices` or None if it is unknown.
            :persistent: Whether the boot device will persist to all
                future boots or not, None if it is unknown.

        """
        driver_info = _parse_driver_info(task.node)
        response = {'boot_device': None}
        try:
            ipmicmd = ipmi_command.Command(bmc=driver_info['address'],
                               userid=driver_info['username'],
                               password=driver_info['password'])
            ret = ipmicmd.get_bootdev()
            # FIXME(lucasagomes): pyghmi doesn't seem to handle errors
            # consistently, for some errors it raises an exception
            # others it just returns a dictionary with the error.
            if 'error' in ret:
                raise pyghmi_exception.IpmiException(ret['error'])
        except pyghmi_exception.IpmiException as e:
            LOG.error(_LE("IPMI get boot device failed for node %(node_id)s "
                          "with the following error: %(error)s"),
                      {'node_id': driver_info['uuid'], 'error': e})
            raise exception.IPMIFailure(cmd=e)

        response['persistent'] = ret.get('persistent')
        bootdev = ret.get('bootdev')
        if bootdev:
            response['boot_device'] = next((dev for dev, hdev in
                                            _BOOT_DEVICES_MAP.items()
                                            if hdev == bootdev), None)
        return response

    def get_sensors_data(self, task):
        """Get sensors data.

        :param task: a TaskManager instance.
        :raises: FailedToGetSensorData when getting the sensor data fails.
        :raises: MissingParameterValue if required ipmi parameters are missing
        :returns: returns a dict of sensor data group by sensor type.

        """
        driver_info = _parse_driver_info(task.node)
        return _get_sensors_data(driver_info)


class NativeIPMIShellinaboxConsole(base.ConsoleInterface):
    """A ConsoleInterface that uses pyghmi and shellinabox."""

    def get_properties(self):
        d = COMMON_PROPERTIES.copy()
        d.update(CONSOLE_PROPERTIES)
        return d

    def validate(self, task):
        """Validate the Node console info.

        :param task: a TaskManager instance containing the node to act on.
        :raises: MissingParameterValue when required IPMI credentials or
            the IPMI terminal port are missing
        :raises: InvalidParameterValue when the IPMI terminal port is not
                an integer.
        """
        driver_info = _parse_driver_info(task.node)
        if not driver_info['port']:
            raise exception.MissingParameterValue(_(
                "Missing 'ipmi_terminal_port' parameter in node's"
                " driver_info."))

    def start_console(self, task):
        """Start a remote console for the node.

        :param task: a TaskManager instance containing the node to act on.
        :raises: MissingParameterValue when required ipmi credentials
                are missing.
        :raises: InvalidParameterValue when the IPMI terminal port is not an
                integer.
        :raises: ConsoleError if unable to start the console process.
        """
        driver_info = _parse_driver_info(task.node)

        path = _console_pwfile_path(driver_info['uuid'])
        pw_file = console_utils.make_persistent_password_file(
                path, driver_info['password'])

        console_cmd = ("/:%(uid)s:%(gid)s:HOME:pyghmicons %(bmc)s"
                       " %(user)s"
                       " %(passwd_file)s"
                       % {'uid': os.getuid(),
                          'gid': os.getgid(),
                          'bmc': driver_info['address'],
                          'user': driver_info['username'],
                          'passwd_file': pw_file})
        try:
            console_utils.start_shellinabox_console(driver_info['uuid'],
                                                    driver_info['port'],
                                                    console_cmd)
        except exception.ConsoleError:
            with excutils.save_and_reraise_exception():
                utils.unlink_without_raise(path)

    def stop_console(self, task):
        """Stop the remote console session for the node.

        :param task: a TaskManager instance containing the node to act on.
        :raises: MissingParameterValue when required IPMI credentials or
            the IPMI terminal port are missing
        :raises: InvalidParameterValue when the IPMI terminal port is not
                an integer.
        :raises: ConsoleError if unable to stop the console process.
        """
        driver_info = _parse_driver_info(task.node)
        try:
            console_utils.stop_shellinabox_console(driver_info['uuid'])
        finally:
            password_file = _console_pwfile_path(driver_info['uuid'])
            utils.unlink_without_raise(password_file)

    def get_console(self, task):
        """Get the type and connection information about the console.

        :param task: a TaskManager instance containing the node to act on.
        :raises: MissingParameterValue when required IPMI credentials or
            the IPMI terminal port are missing
        :raises: InvalidParameterValue when the IPMI terminal port is not
                an integer.
        """
        driver_info = _parse_driver_info(task.node)
        url = console_utils.get_shellinabox_console_url(driver_info['port'])
        return {'type': 'shellinabox', 'url': url}
