From 7ffa36c35e31ce57e7dfc79f8b6053294d093acc Mon Sep 17 00:00:00 2001 From: Chris Krelle Date: Tue, 4 Jun 2013 09:46:15 -0700 Subject: [PATCH] Add ssh power manager. Converting nova's virtual_power_manager for use by ironic Implements: blueprint virtual-power-driver. Change-Id: Ie24564e349aca863578955385f084f1be8b0b053 Authored-by: Devananda van der Veen Authored-by: Chris Krelle --- ironic/common/exception.py | 4 + ironic/common/utils.py | 53 ++- ironic/drivers/ssh.py | 382 +++++++++++++++++++++ ironic/tests/db/utils.py | 18 +- ironic/tests/drivers/test_ssh.py | 549 +++++++++++++++++++++++++++++++ setup.cfg | 2 +- 6 files changed, 973 insertions(+), 35 deletions(-) create mode 100644 ironic/drivers/ssh.py create mode 100644 ironic/tests/drivers/test_ssh.py diff --git a/ironic/common/exception.py b/ironic/common/exception.py index d8b038c084..f74395b69c 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -256,3 +256,7 @@ class ExclusiveLockRequired(NotAuthorized): class IPMIFailure(IronicException): message = _("IPMI command failed: %(cmd)s.") + + +class SSHConnectFailed(IronicException): + message = _("Failed to establish SSH connection to host %(host)s.") diff --git a/ironic/common/utils.py b/ironic/common/utils.py index fee5ef20e8..1c53e154b2 100644 --- a/ironic/common/utils.py +++ b/ironic/common/utils.py @@ -24,6 +24,7 @@ import contextlib import errno import hashlib import os +import paramiko import random import re import shutil @@ -195,42 +196,30 @@ def trycmd(*args, **kwargs): return out, err -def ssh_execute(ssh, cmd, process_input=None, - addl_env=None, check_exit_code=True): - LOG.debug(_('Running cmd (SSH): %s'), cmd) - if addl_env: - raise exception.IronicException(_( - 'Environment not supported over SSH')) +def ssh_connect(connection): + """Method to connect to a remote system using ssh protocol. - if process_input: - # This is (probably) fixable if we need it... - msg = _('process_input not supported over SSH') - raise exception.IronicException(msg) + :param connection: a dict of connection parameters. + :returns: paramiko.SSHClient -- an active ssh connection. + :raises: SSHConnectFailed - stdin_stream, stdout_stream, stderr_stream = ssh.exec_command(cmd) - channel = stdout_stream.channel + """ + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(connection.get('host'), + username=connection.get('username'), + password=connection.get('password', None), + port=connection.get('port', 22), + key_filename=connection.get('key_filename', None), + timeout=connection.get('timeout', 10)) - #stdin.write('process_input would go here') - #stdin.flush() + # send TCP keepalive packets every 20 seconds + ssh.get_transport().set_keepalive(20) + except Exception: + raise exception.SSHConnectFailed(host=connection.get('host')) - # NOTE(justinsb): This seems suspicious... - # ...other SSH clients have buffering issues with this approach - stdout = stdout_stream.read() - stderr = stderr_stream.read() - stdin_stream.close() - - exit_status = channel.recv_exit_status() - - # exit_status == -1 if no exit code was returned - if exit_status != -1: - LOG.debug(_('Result was %s') % exit_status) - if check_exit_code and exit_status != 0: - raise exception.ProcessExecutionError(exit_code=exit_status, - stdout=stdout, - stderr=stderr, - cmd=cmd) - - return (stdout, stderr) + return ssh def generate_uid(topic, size=8): diff --git a/ironic/drivers/ssh.py b/ironic/drivers/ssh.py new file mode 100644 index 0000000000..a9757694a9 --- /dev/null +++ b/ironic/drivers/ssh.py @@ -0,0 +1,382 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# 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 SSH power manager. + +Provides basic power control of virtual machines via SSH. + +For use in dev and test environments. + +Currently supported environments are: + Virtual Box (vbox) + Virsh (virsh) + +""" + +import os + +from oslo.config import cfg + +from ironic.common import exception +from ironic.common import states +from ironic.common import utils +from ironic.drivers import base +from ironic.manager import task_manager +from ironic.openstack.common import jsonutils as json +from ironic.openstack.common import log as logging + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + +COMMAND_SETS = { + 'vbox': { + 'base_cmd': '/usr/bin/VBoxManage', + 'start_cmd': 'startvm {_NodeName_}', + 'stop_cmd': 'controlvm {_NodeName_} poweroff', + 'reboot_cmd': 'controlvm {_NodeName_} reset', + 'list_all': "list vms|awk -F'\"' '{print $2}'", + 'list_running': 'list runningvms', + 'get_node_macs': ("showvminfo --machinereadable {_NodeName_} | " + "grep " + '"macaddress" | awk -F ' + "'" + '"' + "' '{print $2}'") + }, + "virsh": { + 'base_cmd': '/usr/bin/virsh', + 'start_cmd': 'start {_NodeName_}', + 'stop_cmd': 'destroy {_NodeName_}', + 'reboot_cmd': 'reset {_NodeName_}', + 'list_all': "list --all | tail -n +2 | awk -F\" \" '{print $2}'", + 'list_running': + "list --all|grep running|awk -v qc='\"' -F\" \" '{print qc$2qc}'", + 'get_node_macs': ("dumpxml {_NodeName_} | grep " + '"mac address" | awk -F' + '"' + "'" + '" ' + "'{print $2}' | tr -d ':'") + } +} + + +def _normalize_mac(mac): + return mac.translate(None, '-:').lower() + + +def _exec_ssh_command(ssh_obj, command): + """Execute a SSH command on the host.""" + + LOG.debug(_('Running cmd (SSH): %s'), command) + + stdin_stream, stdout_stream, stderr_stream = ssh_obj.exec_command(command) + channel = stdout_stream.channel + + # NOTE(justinsb): This seems suspicious... + # ...other SSH clients have buffering issues with this approach + stdout = stdout_stream.read() + stderr = stderr_stream.read() + stdin_stream.close() + + exit_status = channel.recv_exit_status() + + # exit_status == -1 if no exit code was returned + if exit_status != -1: + LOG.debug(_('Result was %s') % exit_status) + if exit_status != 0: + raise exception.ProcessExecutionError(exit_code=exit_status, + stdout=stdout, + stderr=stderr, + cmd=command) + + return (stdout, stderr) + + +def _parse_control_info(node): + info = json.loads(node.get('control_info', '')) + host = info.get('ssh_host', None) + username = info.get('ssh_user', None) + password = info.get('ssh_pass', None) + port = info.get('ssh_port', 22) + key_filename = info.get('ssh_key', None) + virt_type = info.get('virt_type', None) + + res = { + 'host': host, + 'username': username, + 'port': port, + 'virt_type': virt_type, + 'uuid': node.get('uuid') + } + + if not virt_type: + raise exception.InvalidParameterValue(_( + "SSHPowerDriver requires virt_type be set.")) + + cmd_set = COMMAND_SETS.get(virt_type, None) + if not cmd_set: + raise exception.InvalidParameterValue(_( + "SSHPowerDriver unknown virt_type (%s).") % cmd_set) + res['cmd_set'] = cmd_set + + if not host or not username: + raise exception.InvalidParameterValue(_( + "SSHPowerDriver requires both ssh_host and ssh_user be set.")) + if password: + res['password'] = password + else: + if not key_filename: + raise exception.InvalidParameterValue(_( + "SSHPowerDriver requires either ssh_pass or ssh_key be set.")) + if not os.path.isfile(key_filename): + raise exception.FileNotFound(file_path=key_filename) + res['key_filename'] = key_filename + + return res + + +def _get_power_status(ssh_obj, c_info): + """Returns a node's current power state.""" + + power_state = None + cmd_to_exec = c_info['cmd_set']['list_running'] + running_list = _exec_ssh_command(ssh_obj, cmd_to_exec) + # Command should return a list of running vms. If the current node is + # not listed then we can assume it is not powered on. + node_name = _get_hosts_name_for_node(ssh_obj, c_info) + if node_name: + for node in running_list: + if node_name in node: + power_state = states.POWER_ON + break + if not power_state: + power_state = states.POWER_OFF + else: + power_state = states.ERROR + + return power_state + + +def _get_connection(node): + return utils.ssh_connect(_parse_control_info(node)) + + +def _get_hosts_name_for_node(ssh_obj, c_info): + """Get the name the host uses to reference the node.""" + + matched_name = None + cmd_to_exec = c_info['cmd_set']['list_all'] + full_node_list = _exec_ssh_command(ssh_obj, cmd_to_exec) + # for each node check Mac Addresses + for node in full_node_list: + cmd_to_exec = c_info['cmd_set']['get_node_macs'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', node) + hosts_node_mac_list = _exec_ssh_command(ssh_obj, cmd_to_exec) + + for host_mac in hosts_node_mac_list: + for node_mac in c_info['macs']: + if _normalize_mac(host_mac) in _normalize_mac(node_mac): + matched_name = node + break + + if matched_name: + break + if matched_name: + break + + return matched_name + + +def _power_on(ssh_obj, c_info): + """Power ON this node.""" + + current_pstate = _get_power_status(ssh_obj, c_info) + if current_pstate == states.POWER_ON: + _power_off(ssh_obj, c_info) + + node_name = _get_hosts_name_for_node(ssh_obj, c_info) + cmd_to_power_on = c_info['cmd_set']['start_cmd'] + cmd_to_power_on = cmd_to_power_on.replace('{_NodeName_}', node_name) + + _exec_ssh_command(ssh_obj, cmd_to_power_on) + + current_pstate = _get_power_status(ssh_obj, c_info) + if current_pstate == states.POWER_ON: + return current_pstate + else: + return states.ERROR + + +def _power_off(ssh_obj, c_info): + """Power OFF this node.""" + + current_pstate = _get_power_status(ssh_obj, c_info) + if current_pstate == states.POWER_OFF: + return current_pstate + + node_name = _get_hosts_name_for_node(ssh_obj, c_info) + cmd_to_power_off = c_info['cmd_set']['stop_cmd'] + cmd_to_power_off = cmd_to_power_off.replace('{_NodeName_}', node_name) + + _exec_ssh_command(ssh_obj, cmd_to_power_off) + + current_pstate = _get_power_status(ssh_obj, c_info) + if current_pstate == states.POWER_OFF: + return current_pstate + else: + return states.ERROR + + +def _get_nodes_mac_addresses(task, node): + """Get all mac addresses for a node.""" + interface_ports = task.dbapi.get_ports_by_node(node.get('id')) + macs = [p.address for p in interface_ports] + return macs + + +class SSHPowerDriver(base.ControlDriver): + """SSH Power Driver. + + This ControlDriver class provides a mechanism for controlling the power + state of virtual machines via SSH. + + NOTE: This driver supports VirtualBox and Virsh commands. + NOTE: This driver does not currently support multi-node operations. + + """ + + def __init__(self): + pass + + def validate_driver_info(self, node): + """Check that node['control_info'] contains the requisite fields. + + :param node: Single node object. + + :returns: True / False. + + """ + try: + _parse_control_info(node) + except exception.InvalidParameterValue: + return False + return True + + def get_power_state(self, task, node): + """Get the current power state. + + Poll the host for the current power state of the node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + + :returns: power state. One of :class:`ironic.common.states`. + + """ + c_info = _parse_control_info(node) + c_info['macs'] = _get_nodes_mac_addresses(task, node) + ssh_obj = _get_connection(node) + return _get_power_status(ssh_obj, c_info) + + @task_manager.require_exclusive_lock + def set_power_state(self, task, node, pstate): + """Turn the power on or off. + + Set the power state of a node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + :param pstate: Either POWER_ON or POWER_OFF from :class: + `ironic.common.states`. + + :returns NOTHING: + + Can raise exception.IronicException or exception.PowerStateFailure. + + """ + c_info = _parse_control_info(node) + c_info['macs'] = _get_nodes_mac_addresses(task, node) + ssh_obj = _get_connection(node) + + if pstate == states.POWER_ON: + state = _power_on(ssh_obj, c_info) + elif pstate == states.POWER_OFF: + state = _power_off(ssh_obj, c_info) + else: + raise exception.IronicException(_( + "set_power_state called with invalid power state.")) + + if state != pstate: + raise exception.PowerStateFailure(pstate=pstate) + + @task_manager.require_exclusive_lock + def reboot(self, task, node): + """Cycles the power to a node. + + Power cycles a node. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + + :returns NOTHING: + + Can raise exception.PowerStateFailure. + + """ + c_info = _parse_control_info(node) + c_info['macs'] = _get_nodes_mac_addresses(task, node) + ssh_obj = _get_connection(node) + current_pstate = _get_power_status(ssh_obj, c_info) + if current_pstate == states.POWER_ON: + _power_off(ssh_obj, c_info) + + state = _power_on(ssh_obj, c_info) + + if state != states.POWER_ON: + raise exception.PowerStateFailure(pstate=states.POWER_ON) + + def start_console(self, task, node): + """Starts a console connection to a node. + + CURRENTLY NOT SUPPORTED. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + + Will raise raise exception.IronicException. + + """ + raise exception.IronicException(_( + "start_console is not supported by SSHPowerDriver.")) + + def stop_console(self, task, node): + """Stops a console connection to a node. + + CURRENTLY NOT SUPPORTED. + + :param task: A instance of `ironic.manager.task_manager.TaskManager`. + :param node: A single node. + + Will raise raise exception.IronicException. + + """ + + raise exception.IronicException(_( + "stop_console is not supported by SSHPowerDriver.")) diff --git a/ironic/tests/db/utils.py b/ironic/tests/db/utils.py index afcc819d25..32b76a602b 100644 --- a/ironic/tests/db/utils.py +++ b/ironic/tests/db/utils.py @@ -20,13 +20,22 @@ from ironic.db.sqlalchemy import models from ironic.openstack.common import jsonutils as json -_control_info = json.dumps( +_ipmi_control_info = json.dumps( { "ipmi_address": "1.2.3.4", "ipmi_username": "admin", "ipmi_password": "fake", }) +_ssh_control_info = json.dumps( + { + "ssh_host": "1.2.3.4", + "ssh_user": "admin", + "ssh_pass": "fake", + "ssh_port": 22, + "virt_type": "vbox", + }) + _deploy_info = json.dumps( { "image_path": "/path/to/image.qcow2", @@ -53,7 +62,12 @@ def get_test_node(**kw): '8227348d-5f1d-4488-aad1-7c92b2d42504') node.control_driver = kw.get('control_driver', 'ipmi') - node.control_info = kw.get('control_info', _control_info) + node.control_info = kw.get('control_info', None) + + if node.control_driver == 'ipmi' and not node.control_info: + node.control_info = _ipmi_control_info + elif node.control_driver == 'ssh' and not node.control_info: + node.control_info = _ssh_control_info node.deploy_driver = kw.get('deploy_driver', 'pxe') node.deploy_info = kw.get('deploy_info', _deploy_info) diff --git a/ironic/tests/drivers/test_ssh.py b/ironic/tests/drivers/test_ssh.py new file mode 100644 index 0000000000..c98ed957f7 --- /dev/null +++ b/ironic/tests/drivers/test_ssh.py @@ -0,0 +1,549 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# 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. + +"""Test class for Ironic SSH power driver.""" + +import mox +import paramiko + +from oslo.config import cfg + +from ironic.openstack.common import jsonutils as json + +from ironic.common import exception +from ironic.common import states +from ironic.db import api as dbapi +from ironic.drivers import ssh +from ironic.manager import task_manager +from ironic.tests import base +from ironic.tests.db import utils as db_utils +from ironic.tests.manager import utils as mgr_utils + + +CONF = cfg.CONF + + +class IronicSSHTestCase(base.TestCase): + + def setUp(self): + super(IronicSSHTestCase, self).setUp() + (self.controller, self.deployer) = mgr_utils.get_mocked_node_manager( + control_driver="ssh") + self.node = db_utils.get_test_node(**{'control_driver': 'ssh', + 'deploy_driver': 'fake'}) + self.dbapi = dbapi.get_instance() + self.dbapi.create_node(self.node) + self.ifports = [db_utils.get_test_port(id=6, address='aa:bb:cc'), + db_utils.get_test_port(id=7, address='dd:ee:ff')] + self.dbapi.create_port(self.ifports[0]) + self.dbapi.create_port(self.ifports[1]) + self.ssh = ssh.SSHPowerDriver() + self.test_control_info_dict = { + "ssh_host": "1.2.3.4", + "ssh_user": "admin", + "ssh_pass": "fake", + "ssh_port": 22, + "virt_type": "vbox", + "ssh_key": "/not/real/file"} + + def test__parse_control_info_good(self): + # make sure we get back the expected things + info = ssh._parse_control_info(self.node) + self.assertIsNotNone(info.get('host')) + self.assertIsNotNone(info.get('username')) + self.assertIsNotNone(info.get('password')) + self.assertIsNotNone(info.get('port')) + self.assertIsNotNone(info.get('virt_type')) + self.assertIsNotNone(info.get('cmd_set')) + self.assertIsNotNone(info.get('uuid')) + self.mox.VerifyAll() + + def test__parse_control_info_missing_host(self): + # make sure error is raised when info is missing + tmp_dict = self.test_control_info_dict + del tmp_dict['ssh_host'] + del tmp_dict['ssh_key'] + _ssh_control_info = json.dumps(tmp_dict) + node = db_utils.get_test_node(control_info=_ssh_control_info) + self.assertRaises(exception.InvalidParameterValue, + ssh._parse_control_info, + node) + self.mox.VerifyAll() + + def test__parse_control_info_missing_user(self): + # make sure error is raised when info is missing + tmp_dict = self.test_control_info_dict + del tmp_dict['ssh_user'] + del tmp_dict['ssh_key'] + _ssh_control_info = json.dumps(tmp_dict) + node = db_utils.get_test_node(control_info=_ssh_control_info) + self.assertRaises(exception.InvalidParameterValue, + ssh._parse_control_info, + node) + self.mox.VerifyAll() + + def test__parse_control_info_missing_pass(self): + # make sure error is raised when info is missing + tmp_dict = self.test_control_info_dict + del tmp_dict['ssh_pass'] + del tmp_dict['ssh_key'] + _ssh_control_info = json.dumps(tmp_dict) + node = db_utils.get_test_node(control_info=_ssh_control_info) + self.assertRaises(exception.InvalidParameterValue, + ssh._parse_control_info, + node) + self.mox.VerifyAll() + + def test__parse_control_info_missing_virt_type(self): + # make sure error is raised when info is missing + tmp_dict = self.test_control_info_dict + del tmp_dict['virt_type'] + del tmp_dict['ssh_key'] + _ssh_control_info = json.dumps(tmp_dict) + node = db_utils.get_test_node(control_info=_ssh_control_info) + self.assertRaises(exception.InvalidParameterValue, + ssh._parse_control_info, + node) + self.mox.VerifyAll() + + def test__parse_control_info_missing_key(self): + # make sure error is raised when info is missing + tmp_dict = self.test_control_info_dict + del tmp_dict['ssh_pass'] + _ssh_control_info = json.dumps(tmp_dict) + node = db_utils.get_test_node(control_info=_ssh_control_info) + self.assertRaises(exception.FileNotFound, + ssh._parse_control_info, + node) + self.mox.VerifyAll() + + def test__normalize_mac(self): + mac_raw = "0A:1B-2C-3D:4F" + mac_clean = ssh._normalize_mac(mac_raw) + self.assertEqual(mac_clean, "0a1b2c3d4f") + self.mox.VerifyAll() + + def test__get_power_status_on(self): + info = ssh._parse_control_info(self.node) + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + + ssh._exec_ssh_command( + ssh_obj, info['cmd_set']['list_running']).AndReturn( + ['"NodeName" {b43c4982-110c-4c29-9325-d5f41b053513}']) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn("NodeName") + + self.mox.ReplayAll() + pstate = ssh._get_power_status(ssh_obj, info) + self.assertEqual(pstate, states.POWER_ON) + self.mox.VerifyAll() + + def test__get_power_status_off(self): + info = ssh._parse_control_info(self.node) + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + + ssh._exec_ssh_command( + ssh_obj, info['cmd_set']['list_running']).AndReturn( + ['"NodeName" {b43c4982-110c-4c29-9325-d5f41b053513}']) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn("NotNodeName") + + self.mox.ReplayAll() + pstate = ssh._get_power_status(ssh_obj, info) + self.assertEqual(pstate, states.POWER_OFF) + self.mox.VerifyAll() + + def test__get_power_status_error(self): + info = ssh._parse_control_info(self.node) + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + + ssh._exec_ssh_command( + ssh_obj, info['cmd_set']['list_running']).AndReturn( + ['"NodeName" {b43c4982-110c-4c29-9325-d5f41b053513}']) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn(None) + + self.mox.ReplayAll() + pstate = ssh._get_power_status(ssh_obj, info) + self.assertEqual(pstate, states.ERROR) + self.mox.VerifyAll() + + def test__get_hosts_name_for_node_match(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + ssh._exec_ssh_command(ssh_obj, info['cmd_set']['list_all']).AndReturn( + ['NodeName']) + cmd_to_exec = info['cmd_set']['get_node_macs'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', 'NodeName') + ssh._exec_ssh_command(ssh_obj, cmd_to_exec).AndReturn( + ['52:54:00:cf:2d:31']) + self.mox.ReplayAll() + found_name = ssh._get_hosts_name_for_node(ssh_obj, info) + self.assertEqual(found_name, 'NodeName') + self.mox.VerifyAll() + + def test__get_hosts_name_for_node_no_match(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "22:22:22:22:22:22"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + ssh._exec_ssh_command(ssh_obj, info['cmd_set']['list_all']).AndReturn( + ['NodeName']) + cmd_to_exec = info['cmd_set']['get_node_macs'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', 'NodeName') + ssh._exec_ssh_command(ssh_obj, cmd_to_exec).AndReturn( + ['52:54:00:cf:2d:31']) + + self.mox.ReplayAll() + found_name = ssh._get_hosts_name_for_node(ssh_obj, info) + self.assertEqual(found_name, None) + self.mox.VerifyAll() + + def test__power_on_good(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_get_power_status') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_OFF) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn("NodeName") + cmd_to_exec = info['cmd_set']['start_cmd'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', 'NodeName') + ssh._exec_ssh_command(ssh_obj, cmd_to_exec).AndReturn(None) + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_ON) + self.mox.ReplayAll() + current_state = ssh._power_on(ssh_obj, info) + self.assertEqual(current_state, states.POWER_ON) + self.mox.VerifyAll() + + def test__power_on_fail(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_get_power_status') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_OFF) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn("NodeName") + cmd_to_exec = info['cmd_set']['start_cmd'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', 'NodeName') + ssh._exec_ssh_command(ssh_obj, cmd_to_exec).AndReturn(None) + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_OFF) + self.mox.ReplayAll() + current_state = ssh._power_on(ssh_obj, info) + self.assertEqual(current_state, states.ERROR) + self.mox.VerifyAll() + + def test__power_off_good(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_get_power_status') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_ON) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn("NodeName") + cmd_to_exec = info['cmd_set']['stop_cmd'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', 'NodeName') + ssh._exec_ssh_command(ssh_obj, cmd_to_exec).AndReturn(None) + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_OFF) + self.mox.ReplayAll() + current_state = ssh._power_off(ssh_obj, info) + self.assertEqual(current_state, states.POWER_OFF) + self.mox.VerifyAll() + + def test__power_off_fail(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh, '_get_power_status') + self.mox.StubOutWithMock(ssh, '_get_hosts_name_for_node') + self.mox.StubOutWithMock(ssh, '_exec_ssh_command') + + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_ON) + ssh._get_hosts_name_for_node(ssh_obj, info).AndReturn("NodeName") + cmd_to_exec = info['cmd_set']['stop_cmd'] + cmd_to_exec = cmd_to_exec.replace('{_NodeName_}', 'NodeName') + ssh._exec_ssh_command(ssh_obj, cmd_to_exec).AndReturn(None) + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_ON) + self.mox.ReplayAll() + current_state = ssh._power_off(ssh_obj, info) + self.assertEqual(current_state, states.ERROR) + self.mox.VerifyAll() + + def test_reboot_good(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + self.mox.StubOutWithMock(ssh, '_get_power_status') + self.mox.StubOutWithMock(ssh, '_power_off') + self.mox.StubOutWithMock(ssh, '_power_on') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_ON) + ssh._power_off(ssh_obj, info).AndReturn(None) + ssh._power_on(ssh_obj, info).AndReturn(states.POWER_ON) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + task.resources[0].controller.reboot(task, self.node) + self.mox.VerifyAll() + + def test_reboot_fail(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + self.mox.StubOutWithMock(ssh, '_get_power_status') + self.mox.StubOutWithMock(ssh, '_power_off') + self.mox.StubOutWithMock(ssh, '_power_on') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + ssh._get_power_status(ssh_obj, info).AndReturn(states.POWER_ON) + ssh._power_off(ssh_obj, info).AndReturn(None) + ssh._power_on(ssh_obj, info).AndReturn(states.POWER_OFF) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + self.assertRaises(exception.PowerStateFailure, + task.resources[0].controller.reboot, + task, + self.node) + self.mox.VerifyAll() + + def test_exec_ssh_command_good(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh_obj, 'exec_command') + + class Channel(object): + def recv_exit_status(self): + return 0 + + class Stream(object): + def __init__(self, buffer=''): + self.buffer = buffer + self.channel = Channel() + + def read(self): + return self.buffer + + def close(self): + pass + + ssh_obj.exec_command("command").AndReturn( + (Stream(), Stream('hello'), Stream())) + self.mox.ReplayAll() + stdout, stderr = ssh._exec_ssh_command(ssh_obj, "command") + self.assertEqual(stdout, 'hello') + self.mox.VerifyAll() + + def test_exec_ssh_command_fail(self): + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + ssh_obj = paramiko.SSHClient() + self.mox.StubOutWithMock(ssh_obj, 'exec_command') + + class Channel(object): + def recv_exit_status(self): + return 127 + + class Stream(object): + def __init__(self, buffer=''): + self.buffer = buffer + self.channel = Channel() + + def read(self): + return self.buffer + + def close(self): + pass + + ssh_obj.exec_command("command").AndReturn( + (Stream(), Stream('hello'), Stream())) + self.mox.ReplayAll() + self.assertRaises(exception.ProcessExecutionError, + ssh._exec_ssh_command, + ssh_obj, + "command") + self.mox.VerifyAll() + + def test_start_console(self): + self.assertRaises(exception.IronicException, + self.ssh.start_console, + None, + self.node) + self.mox.VerifyAll() + + def test_stop_console(self): + self.assertRaises(exception.IronicException, + self.ssh.start_console, + None, + self.node) + self.mox.VerifyAll() + + def test_set_power_state_bad_state(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + self.assertRaises(exception.IronicException, + task.resources[0].controller.set_power_state, + task, + self.node, + "BAD_PSTATE") + self.mox.VerifyAll() + + def test_set_power_state_on_good(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + self.mox.StubOutWithMock(ssh, '_power_on') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + ssh._power_on(ssh_obj, info).AndReturn(states.POWER_ON) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + task.resources[0].controller.set_power_state( + task, + self.node, + states.POWER_ON) + self.assert_(True) + self.mox.VerifyAll() + + def test_set_power_state_on_fail(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + self.mox.StubOutWithMock(ssh, '_power_on') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + ssh._power_on(ssh_obj, info).AndReturn(states.POWER_OFF) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + self.assertRaises(exception.PowerStateFailure, + task.resources[0].controller.set_power_state, + task, + self.node, + states.POWER_ON) + self.mox.VerifyAll() + + def test_set_power_state_off_good(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + self.mox.StubOutWithMock(ssh, '_power_off') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + ssh._power_off(ssh_obj, info).AndReturn(states.POWER_OFF) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + task.resources[0].controller.set_power_state( + task, + self.node, + states.POWER_OFF) + self.assert_(True) + self.mox.VerifyAll() + + def test_set_power_state_off_fail(self): + ssh_obj = paramiko.SSHClient() + info = ssh._parse_control_info(self.node) + info['macs'] = ["11:11:11:11:11:11", "52:54:00:cf:2d:31"] + + self.mox.StubOutWithMock(ssh, '_parse_control_info') + self.mox.StubOutWithMock(ssh, '_get_nodes_mac_addresses') + self.mox.StubOutWithMock(ssh, '_get_connection') + self.mox.StubOutWithMock(ssh, '_power_off') + ssh._parse_control_info(self.node).AndReturn(info) + ssh._get_nodes_mac_addresses(mox.IgnoreArg(), self.node).AndReturn( + info['macs']) + ssh._get_connection(self.node).AndReturn(ssh_obj) + ssh._power_off(ssh_obj, info).AndReturn(states.POWER_ON) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']], shared=False) as task: + self.assertRaises(exception.PowerStateFailure, + task.resources[0].controller.set_power_state, + task, + self.node, + states.POWER_OFF) + self.mox.VerifyAll() + + def test__get_nodes_mac_addresses(self): + info = ssh._parse_control_info(self.node) + + self.mox.StubOutWithMock(self.dbapi, 'get_ports_by_node') + self.dbapi.get_ports_by_node(self.node.get('uuid')).\ + AndReturn(self.ifports) + self.dbapi.get_ports_by_node(self.node.get('id')).\ + AndReturn(self.ifports) + self.mox.ReplayAll() + + with task_manager.acquire([info['uuid']]) as task: + node_macs = ssh._get_nodes_mac_addresses(task, self.node) + self.assertEqual(node_macs, ['aa:bb:cc', 'dd:ee:ff']) + self.mox.VerifyAll() diff --git a/setup.cfg b/setup.cfg index 916903ca0e..512142b26a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,7 +35,7 @@ console_scripts = ironic.controllers = fake = ironic.drivers.fake:FakeControlDriver ipmi = ironic.drivers.ipmi:IPMIPowerDriver -# vpd = ironic.drivers.vpd:VirtualPowerDriver + ssh = ironic.drivers.ssh:SSHPowerDriver ironic.deployers = fake = ironic.drivers.fake:FakeDeployDriver