# coding=utf-8

# Copyright 2014 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 console utilities.
"""

import errno
import fcntl
import ipaddress
import os
import signal
import socket
import subprocess
import time

from ironic_lib import utils as ironic_utils
from oslo_concurrency import lockutils
from oslo_log import log as logging
from oslo_service import loopingcall
from oslo_utils import fileutils
import psutil

from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import utils
from ironic.conf import CONF


LOG = logging.getLogger(__name__)

ALLOCATED_PORTS = set()  # in-memory set of already allocated ports
SERIAL_LOCK = 'ironic-console-lock'


def _get_console_pid_dir():
    """Return the directory for the pid file."""

    return CONF.console.terminal_pid_dir or CONF.tempdir


def _ensure_console_pid_dir_exists():
    """Ensure that the console PID directory exists

    Checks that the directory for the console PID file exists
    and if not, creates it.

    :raises: ConsoleError if the directory doesn't exist and cannot be created
    """

    dir = _get_console_pid_dir()
    if not os.path.exists(dir):
        try:
            os.makedirs(dir)
        except OSError as exc:
            msg = (_("Cannot create directory '%(path)s' for console PID file."
                     " Reason: %(reason)s.") % {'path': dir, 'reason': exc})
            LOG.error(msg)
            raise exception.ConsoleError(message=msg)


def _get_console_pid_file(node_uuid):
    """Generate the pid file name to hold the terminal process id."""

    pid_dir = _get_console_pid_dir()
    name = "%s.pid" % node_uuid
    path = os.path.join(pid_dir, name)
    return path


def _get_console_pid(node_uuid):
    """Get the terminal process id from pid file."""

    pid_path = _get_console_pid_file(node_uuid)
    try:
        with open(pid_path, 'r') as f:
            pid_str = f.readline()
            return int(pid_str)
    except (IOError, ValueError, FileNotFoundError):
        raise exception.NoConsolePid(pid_path=pid_path)


def _stop_console(node_uuid):
    """Close the serial console for a node

    Kills the console process and deletes the PID file.

    :param node_uuid: the UUID of the node
    :raises: NoConsolePid if no console PID was found
    :raises: ConsoleError if unable to stop the console process
    """

    try:
        console_pid = _get_console_pid(node_uuid)

        os.kill(console_pid, signal.SIGTERM)

        # make sure the process gets killed hard if required
        attempt = 0
        max_attempts = CONF.console.kill_timeout // 0.2

        while attempt < max_attempts:
            if psutil.pid_exists(console_pid):
                if attempt == max_attempts - 1:
                    os.kill(console_pid, signal.SIGKILL)
                LOG.debug("Waiting for the console process with PID %(pid)s "
                          "to exit. Node: %(node)s.",
                          {'pid': console_pid, 'node': node_uuid})
                time.sleep(0.2)
                attempt += 1
            else:
                break

    except OSError as exc:
        if exc.errno != errno.ESRCH:
            msg = (_("Could not stop the console for node '%(node)s'. "
                     "Reason: %(err)s.") % {'node': node_uuid, 'err': exc})
            raise exception.ConsoleError(message=msg)
        else:
            LOG.warning("Console process for node %s is not running "
                        "but pid file exists.", node_uuid)
    finally:
        ironic_utils.unlink_without_raise(_get_console_pid_file(node_uuid))


def make_persistent_password_file(path, password):
    """Writes a file containing a password until deleted."""

    try:
        fileutils.delete_if_exists(path)
        with open(path, 'wb') as file:
            os.chmod(path, 0o600)
            file.write(password.encode())
        return path
    except Exception as e:
        fileutils.delete_if_exists(path)
        raise exception.PasswordFileFailedToCreate(error=e)


def _get_port_range():
    config_range = CONF.console.port_range

    start, stop = map(int, config_range.split(':'))
    if start >= stop:
        msg = _("[console]port_range should be in the "
                "format <start>:<stop> and start < stop")
        raise exception.InvalidParameterValue(msg)
    return start, stop


def _verify_port(port, host=None):
    """Check whether specified port is in use."""
    ip_version = None
    if host is not None:
        try:
            ip_version = ipaddress.ip_address(host).version
        except ValueError:
            # Assume it's a hostname
            pass
    else:
        host = CONF.host
    if ip_version == 6:
        s = socket.socket(socket.AF_INET6)
    else:
        s = socket.socket()

    try:
        s.bind((host, port))
    except socket.error:
        raise exception.Conflict()
    finally:
        s.close()


@lockutils.synchronized(SERIAL_LOCK)
def acquire_port(host=None):
    """Returns a free TCP port on current host.

    Find and returns a free TCP port in the range
    of 'CONF.console.port_range'.
    """

    start, stop = _get_port_range()

    for port in range(start, stop):
        if port in ALLOCATED_PORTS:
            continue
        try:
            _verify_port(port, host=host)
            ALLOCATED_PORTS.add(port)
            return port
        except exception.Conflict:
            pass

    raise exception.NoFreeIPMITerminalPorts(host=CONF.host)


@lockutils.synchronized(SERIAL_LOCK)
def release_port(port):
    """Release specified TCP port."""
    ALLOCATED_PORTS.discard(port)


def get_shellinabox_console_url(port):
    """Get a url to access the console via shellinaboxd.

    :param port: the terminal port for the node.
    """

    console_host = utils.wrap_ipv6(CONF.my_ip)
    scheme = 'https' if CONF.console.terminal_cert_dir else 'http'
    return '%(scheme)s://%(host)s:%(port)s' % {'scheme': scheme,
                                               'host': console_host,
                                               'port': port}


class _PopenNonblockingPipe(object):
    def __init__(self, source):
        self._source = source
        self._output = b''
        self._wait = False
        self._finished = False
        self._set_async()

    def _set_async(self):
        flags = fcntl.fcntl(self._source, fcntl.F_GETFL)
        fcntl.fcntl(self._source, fcntl.F_SETFL, flags | os.O_NONBLOCK)

    def read(self, num_bytes=4096):
        if self._finished:
            return
        try:
            if self._wait:
                time.sleep(1)
                self._wait = False
            data = os.read(self._source.fileno(), num_bytes)
            self._output += data
            if len(data) < num_bytes:
                self._wait = True
        except OSError:
            self._finished = True

    @property
    def output(self):
        return self._output

    @property
    def finished(self):
        return self._finished


def start_shellinabox_console(node_uuid, port, console_cmd):
    """Open the serial console for a node.

    :param node_uuid: the uuid for the node.
    :param port: the terminal port for the node.
    :param console_cmd: the shell command that gets the console.
    :raises: ConsoleError if the directory for the PID file cannot be created
        or an old process cannot be stopped.
    :raises: ConsoleSubprocessFailed when invoking the subprocess failed.
    """

    # make sure that the old console for this node is stopped
    # and the files are cleared
    try:
        _stop_console(node_uuid)
    except exception.NoConsolePid:
        pass

    _ensure_console_pid_dir_exists()
    pid_file = _get_console_pid_file(node_uuid)

    # put together the command and arguments for invoking the console
    args = []
    args.append(CONF.console.terminal)
    if CONF.console.terminal_cert_dir:
        args.append("-c")
        args.append(CONF.console.terminal_cert_dir)
    else:
        args.append("-t")
    args.append("-p")
    args.append(str(port))
    args.append("--background=%s" % pid_file)
    args.append("-s")
    args.append(console_cmd)

    # run the command as a subprocess
    try:
        LOG.debug('Running subprocess: %s', ' '.join(args))
        # use pipe here to catch the error in case shellinaboxd
        # failed to start.
        obj = subprocess.Popen(args,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    except (OSError, ValueError) as e:
        error = _("%(exec_error)s\n"
                  "Command: %(command)s") % {'exec_error': str(e),
                                             'command': ' '.join(args)}
        LOG.warning(error)
        raise exception.ConsoleSubprocessFailed(error=error)

    error_message = _(
        "Timeout or error while waiting for console subprocess to start for "
        "node: %(node)s.\nCommand: %(command)s.\n") % {
            'node': node_uuid,
            'command': ' '.join(args)}

    stdout_pipe, stderr_pipe = (
        _PopenNonblockingPipe(obj.stdout), _PopenNonblockingPipe(obj.stderr))

    def _wait(node_uuid, popen_obj):
        locals['returncode'] = popen_obj.poll()

        # check if the console pid is created and the process is running.
        # if it is, then the shellinaboxd is invoked successfully as a daemon.
        # otherwise check the error.
        if (locals['returncode'] == 0 and os.path.exists(pid_file)
                and psutil.pid_exists(_get_console_pid(node_uuid))):
            raise loopingcall.LoopingCallDone()

        if locals['returncode'] is not None:
            watched = (stdout_pipe, stderr_pipe)
            while time.time() < expiration and not all(
                    (i.finished for i in watched)):
                for pipe in watched:
                    pipe.read()
            locals['errstr'] = error_message + _(
                "Exit code: %(return_code)s.\nStdout: %(stdout)r\n"
                "Stderr: %(stderr)r") % {
                    'return_code': locals['returncode'],
                    'stdout': stdout_pipe.output, 'stderr': stderr_pipe.output}
            raise loopingcall.LoopingCallDone()

        if time.time() > expiration:
            locals['errstr'] = error_message
            raise loopingcall.LoopingCallDone()

    locals = {'returncode': None, 'errstr': ''}
    expiration = time.time() + CONF.console.subprocess_timeout
    timer = loopingcall.FixedIntervalLoopingCall(_wait, node_uuid, obj)
    timer.start(interval=CONF.console.subprocess_checking_interval).wait()

    if locals['errstr']:
        LOG.warning(locals['errstr'])
        raise exception.ConsoleSubprocessFailed(error=locals['errstr'])


def stop_shellinabox_console(node_uuid):
    """Close the serial console for a node.

    :param node_uuid: the UUID of the node
    :raises: ConsoleError if unable to stop the console process
    """

    try:
        _stop_console(node_uuid)
    except exception.NoConsolePid:
        LOG.warning("No console pid found for node %s while trying to "
                    "stop shellinabox console.", node_uuid)


def get_socat_console_url(port):
    """Get a URL to access the console via socat.

    :param port: the terminal port (integer) for the node
    :return: an access URL to the socat console of the node
    """
    console_host = utils.wrap_ipv6(CONF.console.socat_address)
    return 'tcp://%(host)s:%(port)s' % {'host': console_host,
                                        'port': port}


def start_socat_console(node_uuid, port, console_cmd):
    """Open the serial console for a node.

    :param node_uuid: the uuid of the node
    :param port: the terminal port for the node
    :param console_cmd: the shell command that will be executed by socat to
        establish console to the node
    :raises ConsoleError: if the directory for the PID file or the PID file
        cannot be created
    :raises ConsoleSubprocessFailed: when invoking the subprocess failed
    """
    # Make sure that the old console for this node is stopped.
    # If no console is running, we may get exception NoConsolePid.
    try:
        _stop_console(node_uuid)
    except exception.NoConsolePid:
        pass

    _ensure_console_pid_dir_exists()
    pid_file = _get_console_pid_file(node_uuid)

    # put together the command and arguments for invoking the console
    args = ['socat']
    # set timeout check for user's connection. If the timeout value
    # is not 0, after timeout seconds of inactivity on the client side,
    # the connection will be closed.
    if CONF.console.terminal_timeout > 0:
        args.append('-T%d' % CONF.console.terminal_timeout)
    args.append('-L%s' % pid_file)

    console_host = CONF.console.socat_address
    if ipaddress.ip_address(console_host).version == 6:
        arg = ('TCP6-LISTEN:%(port)s,bind=[%(host)s],reuseaddr,fork,'
               'max-children=1')
    else:
        arg = ('TCP4-LISTEN:%(port)s,bind=%(host)s,reuseaddr,fork,'
               'max-children=1')
    args.append(arg % {'host': console_host,
                       'port': port})

    args.append('EXEC:"%s",pty,stderr' % console_cmd)

    # run the command as a subprocess
    try:
        LOG.debug('Running subprocess: %s', ' '.join(args))
        # Use pipe here to catch the error in case socat
        # fails to start. Note that socat uses stdout as transferring
        # data, so we only capture stderr for checking if it fails.
        obj = subprocess.Popen(args, stderr=subprocess.PIPE)
    except (OSError, ValueError) as e:
        error = _("%(exec_error)s\n"
                  "Command: %(command)s") % {'exec_error': str(e),
                                             'command': ' '.join(args)}
        LOG.exception('Unable to start socat console')
        raise exception.ConsoleSubprocessFailed(error=error)

    # NOTE: we need to check if socat fails to start here.
    # If it starts successfully, it will run in non-daemon mode and
    # will not return until the console session is stopped.

    def _wait(node_uuid, popen_obj):
        wait_state['returncode'] = popen_obj.poll()

        # socat runs in non-daemon mode, so it should not return now
        if wait_state['returncode'] is None:
            # If the pid file is created and the process is running,
            # we stop checking it periodically.
            if (os.path.exists(pid_file)
                    and psutil.pid_exists(_get_console_pid(node_uuid))):
                raise loopingcall.LoopingCallDone()
        else:
            # socat returned, it failed to start.
            # We get the error (out should be None in this case).
            (_out, err) = popen_obj.communicate()
            wait_state['errstr'] = _(
                "Command: %(command)s.\n"
                "Exit code: %(return_code)s.\n"
                "Stderr: %(error)r") % {
                    'command': ' '.join(args),
                    'return_code': wait_state['returncode'],
                    'error': err}
            LOG.error(wait_state['errstr'])
            raise loopingcall.LoopingCallDone()

        if time.time() > expiration:
            wait_state['errstr'] = (_("Timeout while waiting for console "
                                      "subprocess to start for node %s.") %
                                    node_uuid)
            LOG.error(wait_state['errstr'])
            raise loopingcall.LoopingCallDone()

    wait_state = {'returncode': None, 'errstr': ''}
    expiration = time.time() + CONF.console.subprocess_timeout
    timer = loopingcall.FixedIntervalLoopingCall(_wait, node_uuid, obj)
    timer.start(interval=CONF.console.subprocess_checking_interval).wait()

    if wait_state['errstr']:
        raise exception.ConsoleSubprocessFailed(error=wait_state['errstr'])


def stop_socat_console(node_uuid):
    """Close the serial console for a node.

    :param node_uuid: the UUID of the node
    :raise ConsoleError: if unable to stop the console process
    """
    try:
        _stop_console(node_uuid)
    except exception.NoConsolePid:
        LOG.warning("No console pid found for node %s while trying to "
                    "stop socat console.", node_uuid)
