Restructuring driver API and inheritance.

Based on discussions during and after the Ironic team meeting on June
03, regarding support for substantially different driver work flows,
this is a re-working of the internal driver API.

tl;dr: The strict separation of "control" and "deploy" driver was an
       artefact of the ipmi + pxe implementation used in nova-baremetal,
       and does not map on to all drivers. Furthermore, the prior
       implementation did not accurately represent the separation of
       "core", "standard", and "vendor-specific" driver functionality.

These changes impact the v1 API structure, but since that is largely not
implemented yet, this change does not attempt to affect the public API
itself.

Highlights:
- No more deploy + control driver; nodes have one and only one driver.
  This drops the deploy_driver and deploy_info parameters,
  and renames control_driver -> driver, and control_info -> driver_info.
- Interfaces for core, standard, and vendor functionality now clearly
  defined in the driver API.
- Improve Fake driver to demonstrate use of interfaces.
- Convert IPMI and SSH driver classes into interfaces, and move to
  drivers/modules/ directory.
- Stub for the pxe interfaces.
- Stub implementations of pxe+ipmi and pxe+ssh drivers.
- driver_info field uses more standard names, but requires
  driver-specific data to be in a nested object. Examples in
  tests/db/utils.py as before.

A separate doc change will follow this to update the API v1 spec.

Also includes some cosmetic cleanup of test_ssh.py and test_ipmi.py.

Change-Id: I057ede8e07b1b57010e81ef58415debe0ba8b934
This commit is contained in:
Devananda van der Veen 2013-06-04 12:19:18 -07:00
parent 3c02cb68cd
commit 5e0e2492e9
6 changed files with 61 additions and 477 deletions

View File

@ -60,7 +60,8 @@ LOG = logging.getLogger(__name__)
VALID_BOOT_DEVICES = ['pxe', 'disk', 'safe', 'cdrom', 'bios']
# TODO(deva): use a contextmanager for this, and port it to nova.
# TODO(deva): replace this with remove_path_on_error once that is available
# https://review.openstack.org/#/c/31030
def _make_password_file(password):
fd, path = tempfile.mkstemp()
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
@ -69,37 +70,38 @@ def _make_password_file(password):
return path
def _parse_control_info(node):
info = json.loads(node.get('control_info', ''))
address = info.get('ipmi_address', None)
user = info.get('ipmi_username', None)
password = info.get('ipmi_password', None)
port = info.get('ipmi_terminal_port', None)
def _parse_driver_info(node):
driver_info = json.loads(node.get('driver_info', ''))
ipmi_info = driver_info.get('ipmi')
address = ipmi_info.get('address', None)
username = ipmi_info.get('username', None)
password = ipmi_info.get('password', None)
port = ipmi_info.get('terminal_port', None)
if not address or not user or not password:
if not address or not username or not password:
raise exception.InvalidParameterValue(_(
"IPMI credentials not supplied to IPMI driver."))
return {
'address': address,
'user': user,
'username': username,
'password': password,
'port': port,
'uuid': node.get('uuid')
}
def _exec_ipmitool(c_info, command):
def _exec_ipmitool(driver_info, command):
args = ['ipmitool',
'-I',
'lanplus',
'-H',
c_info['address'],
driver_info['address'],
'-U',
c_info['user'],
driver_info['username'],
'-f']
try:
pwfile = _make_password_file(c_info['password'])
pwfile = _make_password_file(driver_info['password'])
args.append(pwfile)
args.extend(command.split(" "))
out, err = utils.execute(*args, attempts=3)
@ -110,7 +112,7 @@ def _exec_ipmitool(c_info, command):
utils.delete_if_exists(pwfile)
def _power_on(c_info):
def _power_on(driver_info):
"""Turn the power to this node ON."""
# use mutable objects so the looped method can change them
@ -120,7 +122,7 @@ def _power_on(c_info):
def _wait_for_power_on(state, retries):
"""Called at an interval until the node's power is on."""
state[0] = _power_status(c_info)
state[0] = _power_status(driver_info)
if state[0] == states.POWER_ON:
raise loopingcall.LoopingCallDone()
@ -129,11 +131,11 @@ def _power_on(c_info):
raise loopingcall.LoopingCallDone()
try:
retries[0] += 1
_exec_ipmitool(c_info, "power on")
_exec_ipmitool(driver_info, "power on")
except Exception:
# Log failures but keep trying
LOG.warning(_("IPMI power on failed for node %s.")
% c_info['uuid'])
% driver_info['uuid'])
timer = loopingcall.FixedIntervalLoopingCall(_wait_for_power_on,
state, retries)
@ -141,7 +143,7 @@ def _power_on(c_info):
return state[0]
def _power_off(c_info):
def _power_off(driver_info):
"""Turn the power to this node OFF."""
# use mutable objects so the looped method can change them
@ -151,7 +153,7 @@ def _power_off(c_info):
def _wait_for_power_off(state, retries):
"""Called at an interval until the node's power is off."""
state[0] = _power_status(c_info)
state[0] = _power_status(driver_info)
if state[0] == states.POWER_OFF:
raise loopingcall.LoopingCallDone()
@ -160,11 +162,11 @@ def _power_off(c_info):
raise loopingcall.LoopingCallDone()
try:
retries[0] += 1
_exec_ipmitool(c_info, "power off")
_exec_ipmitool(driver_info, "power off")
except Exception:
# Log failures but keep trying
LOG.warning(_("IPMI power off failed for node %s.")
% c_info['uuid'])
% driver_info['uuid'])
timer = loopingcall.FixedIntervalLoopingCall(_wait_for_power_off,
state=state, retries=retries)
@ -172,8 +174,8 @@ def _power_off(c_info):
return state[0]
def _power_status(c_info):
out_err = _exec_ipmitool(c_info, "power status")
def _power_status(driver_info):
out_err = _exec_ipmitool(driver_info, "power status")
if out_err[0] == "Chassis Power is on\n":
return states.POWER_ON
elif out_err[0] == "Chassis Power is off\n":
@ -182,41 +184,30 @@ def _power_status(c_info):
return states.ERROR
class IPMIPowerDriver(base.ControlDriver):
"""Generic IPMI Power Driver
class IPMIPower(base.PowerInterface):
This ControlDriver class provides mechanism for controlling the power state
of physical hardware via IPMI calls. It also provides console access for
some supported hardware.
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."""
def validate(self, node):
"""Check that node['driver_info'] contains IPMI credentials."""
try:
_parse_control_info(node)
_parse_driver_info(node)
except exception.InvalidParameterValue:
return False
return True
def get_power_state(self, task, node):
"""Get the current power state."""
c_info = _parse_control_info(node)
return _power_status(c_info)
driver_info = _parse_driver_info(node)
return _power_status(driver_info)
@task_manager.require_exclusive_lock
def set_power_state(self, task, node, pstate):
"""Turn the power on or off."""
c_info = _parse_control_info(node)
driver_info = _parse_driver_info(node)
if pstate == states.POWER_ON:
state = _power_on(c_info)
state = _power_on(driver_info)
elif pstate == states.POWER_OFF:
state = _power_off(c_info)
state = _power_off(driver_info)
else:
raise exception.IronicException(_(
"set_power_state called with invalid power state."))
@ -227,18 +218,18 @@ class IPMIPowerDriver(base.ControlDriver):
@task_manager.require_exclusive_lock
def reboot(self, task, node):
"""Cycles the power to a node."""
c_info = _parse_control_info(node)
_power_off(c_info)
state = _power_on(c_info)
driver_info = _parse_driver_info(node)
_power_off(driver_info)
state = _power_on(driver_info)
if state != states.POWER_ON:
raise exception.PowerStateFailure(pstate=states.POWER_ON)
@task_manager.require_exclusive_lock
def set_boot_device(self, task, node, device, persistent=False):
def _set_boot_device(self, task, node, device, persistent=False):
"""Set the boot device for a node.
:param task: TaskManager context.
:param task: a TaskManager instance.
:param node: The Node.
:param device: Boot device. One of [pxe, disk, cdrom, safe, bios].
:param persistent: Whether to set next-boot, or make the change
@ -253,19 +244,9 @@ class IPMIPowerDriver(base.ControlDriver):
cmd = "chassis bootdev %s" % device
if persistent:
cmd = cmd + " options=persistent"
c_info = _parse_control_info(node)
driver_info = _parse_driver_info(node)
try:
out, err = _exec_ipmitool(c_info, cmd)
out, err = _exec_ipmitool(driver_info, cmd)
# TODO(deva): validate (out, err) and add unit test for failure
except Exception:
raise exception.IPMIFailure(cmd=cmd)
# TODO(deva): port start_console
def start_console(self, task, node):
raise exception.IronicException(_(
"start_console is not supported by IPMIPowerDriver."))
# TODO(deva): port stop_console
def stop_console(self, task, node):
raise exception.IronicException(_(
"stop_console is not supported by IPMIPowerDriver."))

View File

@ -1,382 +0,0 @@
# 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."))

View File

@ -75,8 +75,8 @@ class ManagerService(service.PeriodicService):
with task_manager.acquire([node_id], shared=True) as task:
node = task.resources[0].node
driver = task.resources[0].controller
state = driver.get_power_state(task, node)
driver = task.resources[0].driver
state = driver.power.get_power_state(task, node)
return state
# TODO(deva)

View File

@ -46,12 +46,8 @@ class NodeManager(object):
_nodes = {}
_control_factory = dispatch.NameDispatchExtensionManager(
namespace='ironic.controllers',
check_func=lambda x: True,
invoke_on_load=True)
_deploy_factory = dispatch.NameDispatchExtensionManager(
namespace='ironic.deployers',
_driver_factory = dispatch.NameDispatchExtensionManager(
namespace='ironic.drivers',
check_func=lambda x: True,
invoke_on_load=True)
@ -69,22 +65,13 @@ class NodeManager(object):
# NOTE(deva): Driver loading here may get refactored, depend on:
# https://github.com/dreamhost/stevedore/issues/15
try:
ref = NodeManager._control_factory.map(
[self.node.get('control_driver')], _get_instance)
self.controller = ref[0]
ref = NodeManager._driver_factory.map(
[self.node.get('driver')], _get_instance)
self.driver = ref[0]
except KeyError:
raise exception.IronicException(_(
"Failed to load Control driver %s.") %
self.node.get('control_driver'))
try:
ref = NodeManager._deploy_factory.map(
[self.node.get('deploy_driver')], _get_instance)
self.deployer = ref[0]
except KeyError:
raise exception.IronicException(_(
"Failed to load Deploy driver %s.") %
self.node.get('deploy_driver'))
"Failed to load driver %s.") %
self.node.get('driver'))
@classmethod
@lockutils.synchronized(RESOURCE_MANAGER_SEMAPHORE, 'ironic-')

View File

@ -23,7 +23,7 @@ A context manager to peform a series of tasks on a set of resources.
locking and simplify operations across a set of
:class:`ironic.manager.resource_manager.NodeManager` instances. Each
NodeManager holds the data model for a node, as well as references to the
controller and deployer driver singletons appropriate for that node.
driver singleton appropriate for that node.
The :class:`TaskManager` will acquire either a shared or exclusive lock, as
indicated. Multiple shared locks for the same resource may coexist with an
@ -56,11 +56,10 @@ driver function, you can access the drivers directly in this way::
with task_manager.acquire(node_ids) as task:
states = []
for node, control, deploy in [r.node, r.controller, r.deployer
for node, driver in [r.node, r.driver
for r in task.resources]:
# control and deploy are driver singletons,
# loaded based on that node's configuration.
states.append(control.get_power_state(task, node)
# the driver is loaded based on that node's configuration.
states.append(driver.power.get_power_state(task, node)
"""
import contextlib

View File

@ -33,14 +33,13 @@ console_scripts =
ironic-manager = ironic.cmd.manager:main
ironic-rootwrap = ironic.openstack.common.rootwrap.cmd:main
ironic.controllers =
fake = ironic.drivers.fake:FakeControlDriver
ipmi = ironic.drivers.ipmi:IPMIPowerDriver
ssh = ironic.drivers.ssh:SSHPowerDriver
ironic.deployers =
fake = ironic.drivers.fake:FakeDeployDriver
# pxe = ironic.drivers.pxe.PXEDeployDriver
ironic.drivers =
fake = ironic.drivers.fake:FakeDriver
fake_ipmi = ironic.drivers.fake:FakeIPMIDriver
fake_ssh = ironic.drivers.fake:FakeSSHDriver
fake_pxe = ironic.drivers.fake:FakePXEDriver
pxe_ipmi = ironic.drivers.pxe:PXEAndIPMIDriver
pxe_ssh = ironic.drivers.pxe:PXEAndSSHDriver
[pbr]
autodoc_index_modules = True