# 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 os import subprocess import tempfile import time from oslo_concurrency import processutils from oslo_config import cfg from oslo_utils import netutils from ironic.common import exception from ironic.common.i18n import _ from ironic.common.i18n import _LW from ironic.common import utils from ironic.openstack.common import log as logging from ironic.openstack.common import loopingcall opts = [ cfg.StrOpt('terminal', default='shellinaboxd', help='Path to serial console terminal program'), cfg.StrOpt('terminal_cert_dir', help='Directory containing the terminal SSL cert(PEM) for ' 'serial console access'), cfg.StrOpt('terminal_pid_dir', help='Directory for holding terminal pid files. ' 'If not specified, the temporary directory will be used.'), cfg.IntOpt('subprocess_checking_interval', default=1, help='Time interval (in seconds) for checking the status of ' 'console subprocess.'), cfg.IntOpt('subprocess_timeout', default=10, help='Time (in seconds) to wait for the console subprocess ' 'to start.'), ] CONF = cfg.CONF CONF.register_opts(opts, group='console') LOG = logging.getLogger(__name__) def _get_console_pid_dir(): """Return the directory for the pid file.""" if CONF.console.terminal_pid_dir: pid_dir = CONF.console.terminal_pid_dir else: pid_dir = tempfile.gettempdir() return pid_dir 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): 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: processutils.ProcessExecutionError if unable to stop the process """ try: console_pid = _get_console_pid(node_uuid) # Allow exitcode 99 (RC_UNAUTHORIZED) utils.execute('kill', str(console_pid), check_exit_code=[0, 99]) finally: 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: utils.delete_if_exists(path) with open(path, 'wb') as file: os.chmod(path, 0o600) file.write(password) return path except Exception as e: utils.delete_if_exists(path) raise exception.PasswordFileFailedToCreate(error=str(e)) 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 = CONF.my_ip if netutils.is_valid_ipv6(console_host): console_host = '[%s]' % console_host console_url = "http://%s:%s" % (console_host, port) return console_url 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. :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 except processutils.ProcessExecutionError as exc: LOG.warning(_LW("Failed to kill the old console process " "before starting a new shellinabox console " "for node %(node)s. Reason: %(err)s"), {'node': node_uuid, 'err': exc}) _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) def _wait(node_uuid, popen_obj): locals['returncode'] = popen_obj.poll() # check if the console pid is created. # if it is, then the shellinaboxd is invoked successfully as a daemon. # otherwise check the error. if locals['returncode'] is not None: if locals['returncode'] == 0 and os.path.exists(pid_file): raise loopingcall.LoopingCallDone() else: (stdout, stderr) = popen_obj.communicate() locals['errstr'] = _("Command: %(command)s.\n" "Exit code: %(return_code)s.\n" "Stdout: %(stdout)r\n" "Stderr: %(stderr)r") % {'command': ' '.join(args), 'return_code': locals['returncode'], 'stdout': stdout, 'stderr': stderr} LOG.warning(locals['errstr']) raise loopingcall.LoopingCallDone() if (time.time() > expiration): locals['errstr'] = _("Timeout while waiting for console" " subprocess to start for node %s.") % node_uuid LOG.warning(locals['errstr']) 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']: 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(_LW("No console pid found for node %s while trying to " "stop shellinabox console."), node_uuid) except processutils.ProcessExecutionError as exc: msg = (_("Could not stop the console for node '%(node)s'. " "Reason: %(err)s.") % {'node': node_uuid, 'err': exc}) raise exception.ConsoleError(message=msg)