Add Mistral action & workflow for root device configuration
The root device configuration code and unit tests come from the python-tripleoclient project. By moving them into a common Mistral action, they can be reused by other clients like the UI. The workflows will also be used for the new 'overcloud node configure' command in the client. Change-Id: I5ba0a3710012c44822dd3b8e69662bbef04d3787 Related-Bug: #1595205
This commit is contained in:
parent
a9c8ffb655
commit
cbe0dbf32d
@ -13,3 +13,4 @@ python-glanceclient>=2.0.0 # Apache-2.0
|
||||
python-ironicclient>=1.1.0 # Apache-2.0
|
||||
six>=1.9.0 # MIT
|
||||
mistral!=2015.1.0,>=2.0.0 # Apache-2.0
|
||||
python-ironic-inspector-client>=1.5.0 # Apache-2.0
|
||||
|
@ -59,6 +59,7 @@ mistral.actions =
|
||||
tripleo.upload_default_templates = tripleo_common.actions.templates:UploadTemplatesAction
|
||||
tripleo.process_templates = tripleo_common.actions.templates:ProcessTemplatesAction
|
||||
tripleo.baremetal.configure_boot = tripleo_common.actions.baremetal:ConfigureBootAction
|
||||
tripleo.baremetal.configure_root_device = tripleo_common.actions.baremetal:ConfigureRootDeviceAction
|
||||
tripleo.create_container = tripleo_common.actions.plan:CreateContainerAction
|
||||
tripleo.create_plan = tripleo_common.actions.plan:CreatePlanAction
|
||||
tripleo.deploy_config = tripleo_common.actions.deployment:OrchestrationDeployAction
|
||||
|
@ -14,8 +14,12 @@
|
||||
# under the License.
|
||||
import logging
|
||||
|
||||
import ironic_inspector_client
|
||||
from mistral.workflow import utils as mistral_workflow_utils
|
||||
from oslo_utils import units
|
||||
|
||||
from tripleo_common.actions import base
|
||||
from tripleo_common import exception
|
||||
from tripleo_common.utils import glance
|
||||
from tripleo_common.utils import nodes
|
||||
|
||||
@ -131,3 +135,125 @@ class ConfigureBootAction(base.TripleOAction):
|
||||
except Exception as err:
|
||||
LOG.exception("Error configuring node boot options with Ironic.")
|
||||
return mistral_workflow_utils.Result("", err)
|
||||
|
||||
|
||||
class ConfigureRootDeviceAction(base.TripleOAction):
|
||||
"""Configure the root device strategy.
|
||||
|
||||
:param node_uuid: an Ironic node UUID
|
||||
:param root_device: Define the root device for nodes. Can be either a list
|
||||
of device names (without /dev) to choose from or one
|
||||
of two strategies: largest or smallest. For it to work
|
||||
this command should be run after the introspection.
|
||||
:param minimum_size: Minimum size (in GiB) of the detected root device.
|
||||
:param overwrite: Whether to overwrite existing root device hints when
|
||||
root-device is set.
|
||||
"""
|
||||
|
||||
def __init__(self, node_uuid, root_device=None, minimum_size=4,
|
||||
overwrite=False):
|
||||
super(ConfigureRootDeviceAction, self).__init__()
|
||||
self.node_uuid = node_uuid
|
||||
self.root_device = root_device
|
||||
self.minimum_size = minimum_size
|
||||
self.overwrite = overwrite
|
||||
|
||||
def run(self):
|
||||
if not self.root_device:
|
||||
return
|
||||
|
||||
baremetal_client = self._get_baremetal_client()
|
||||
node = baremetal_client.node.get(self.node_uuid)
|
||||
self._apply_root_device_strategy(
|
||||
node, self.root_device, self.minimum_size, self.overwrite)
|
||||
|
||||
def _apply_root_device_strategy(self, node, strategy, minimum_size,
|
||||
overwrite=False):
|
||||
if node.properties.get('root_device') and not overwrite:
|
||||
# This is a correct situation, we still want to allow people to
|
||||
# fine-tune the root device setting for a subset of nodes.
|
||||
# However, issue a warning, so that they know which nodes were not
|
||||
# updated during this run.
|
||||
LOG.warning('Root device hints are already set for node %s '
|
||||
'and overwriting is not requested, skipping',
|
||||
node.uuid)
|
||||
LOG.warning('You may unset them by running $ ironic '
|
||||
'node-update %s remove properties/root_device',
|
||||
node.uuid)
|
||||
return
|
||||
|
||||
inspector_client = self._get_baremetal_introspection_client()
|
||||
try:
|
||||
data = inspector_client.get_data(node.uuid)
|
||||
except ironic_inspector_client.ClientError:
|
||||
raise exception.RootDeviceDetectionError(
|
||||
'No introspection data found for node %s, '
|
||||
'root device cannot be detected' % node.uuid)
|
||||
except AttributeError:
|
||||
raise RuntimeError('Ironic inspector client version 1.2.0 or '
|
||||
'newer is required for detecting root device')
|
||||
|
||||
try:
|
||||
disks = data['inventory']['disks']
|
||||
except KeyError:
|
||||
raise exception.RootDeviceDetectionError(
|
||||
'Malformed introspection data for node %s: '
|
||||
'disks list is missing' % node.uuid)
|
||||
|
||||
minimum_size *= units.Gi
|
||||
disks = [d for d in disks if d.get('size', 0) >= minimum_size]
|
||||
|
||||
if not disks:
|
||||
raise exception.RootDeviceDetectionError(
|
||||
'No suitable disks found for node %s' % node.uuid)
|
||||
|
||||
if strategy == 'smallest':
|
||||
disks.sort(key=lambda d: d['size'])
|
||||
root_device = disks[0]
|
||||
elif strategy == 'largest':
|
||||
disks.sort(key=lambda d: d['size'], reverse=True)
|
||||
root_device = disks[0]
|
||||
else:
|
||||
disk_names = [x.strip() for x in strategy.split(',')]
|
||||
disks = {d['name']: d for d in disks}
|
||||
for candidate in disk_names:
|
||||
try:
|
||||
root_device = disks['/dev/%s' % candidate]
|
||||
except KeyError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
raise exception.RootDeviceDetectionError(
|
||||
'Cannot find a disk with any of names %(strategy)s '
|
||||
'for node %(node)s' %
|
||||
{'strategy': strategy, 'node': node.uuid})
|
||||
|
||||
hint = None
|
||||
for hint_name in ('wwn', 'serial'):
|
||||
if root_device.get(hint_name):
|
||||
hint = {hint_name: root_device[hint_name]}
|
||||
break
|
||||
|
||||
if hint is None:
|
||||
# I don't think it might actually happen, but just in case
|
||||
raise exception.RootDeviceDetectionError(
|
||||
'Neither WWN nor serial number are known for device %(dev)s '
|
||||
'on node %(node)s; root device hints cannot be used' %
|
||||
{'dev': root_device['name'], 'node': node.uuid})
|
||||
|
||||
# During the introspection process we got local_gb assigned according
|
||||
# to the default strategy. Now we need to update it.
|
||||
new_size = root_device['size'] / units.Gi
|
||||
# This -1 is what we always do to account for partitioning
|
||||
new_size -= 1
|
||||
|
||||
bm_client = self._get_baremetal_client()
|
||||
bm_client.node.update(
|
||||
node.uuid,
|
||||
[{'op': 'add', 'path': '/properties/root_device', 'value': hint},
|
||||
{'op': 'add', 'path': '/properties/local_gb', 'value': new_size}])
|
||||
|
||||
LOG.info('Updated root device for node %(node)s, new device '
|
||||
'is %(dev)s, new local_gb is %(local_gb)d',
|
||||
{'node': node.uuid, 'dev': root_device, 'local_gb': new_size})
|
||||
|
@ -14,6 +14,7 @@
|
||||
# under the License.
|
||||
from glanceclient.v2 import client as glanceclient
|
||||
from heatclient.v1 import client as heatclient
|
||||
import ironic_inspector_client
|
||||
from ironicclient.v1 import client as ironicclient
|
||||
from mistral.actions import base
|
||||
from mistral import context
|
||||
@ -50,6 +51,19 @@ class TripleOAction(base.Action):
|
||||
os_ironic_api_version='1.11'
|
||||
)
|
||||
|
||||
def _get_baremetal_introspection_client(self):
|
||||
ctx = context.ctx()
|
||||
|
||||
bmi_endpoint = keystone_utils.get_endpoint_for_project(
|
||||
'ironic-inspector')
|
||||
|
||||
return ironic_inspector_client.ClientV1(
|
||||
api_version='1.2',
|
||||
inspector_url=bmi_endpoint.url,
|
||||
region_name=bmi_endpoint.region,
|
||||
auth_token=ctx.auth_token
|
||||
)
|
||||
|
||||
def _get_image_client(self):
|
||||
ctx = context.ctx()
|
||||
|
||||
|
@ -107,3 +107,7 @@ class StateTransitionFailed(Exception):
|
||||
}
|
||||
)
|
||||
super(StateTransitionFailed, self).__init__(message)
|
||||
|
||||
|
||||
class RootDeviceDetectionError(Exception):
|
||||
"""Failed to detect the root device"""
|
||||
|
@ -14,8 +14,11 @@
|
||||
import mock
|
||||
|
||||
from glanceclient import exc as glance_exceptions
|
||||
import ironic_inspector_client
|
||||
from oslo_utils import units
|
||||
|
||||
from tripleo_common.actions import baremetal
|
||||
from tripleo_common import exception
|
||||
from tripleo_common.tests import base
|
||||
|
||||
|
||||
@ -135,3 +138,195 @@ class TestConfigureBootAction(base.TestCase):
|
||||
result = action.run()
|
||||
|
||||
self.assertIn("Update error", str(result.error))
|
||||
|
||||
|
||||
class TestConfigureRootDeviceAction(base.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestConfigureRootDeviceAction, self).setUp()
|
||||
|
||||
# Mock data
|
||||
self.disks = [
|
||||
{'name': '/dev/sda', 'size': 11 * units.Gi},
|
||||
{'name': '/dev/sdb', 'size': 2 * units.Gi},
|
||||
{'name': '/dev/sdc', 'size': 5 * units.Gi},
|
||||
{'name': '/dev/sdd', 'size': 21 * units.Gi},
|
||||
{'name': '/dev/sde', 'size': 13 * units.Gi},
|
||||
]
|
||||
for i, disk in enumerate(self.disks):
|
||||
disk['wwn'] = 'wwn%d' % i
|
||||
disk['serial'] = 'serial%d' % i
|
||||
|
||||
# Ironic mocks
|
||||
self.ironic = mock.MagicMock()
|
||||
ironic_patcher = mock.patch(
|
||||
'tripleo_common.actions.base.TripleOAction._get_baremetal_client',
|
||||
return_value=self.ironic)
|
||||
self.mock_ironic = ironic_patcher.start()
|
||||
self.addCleanup(self.mock_ironic.stop)
|
||||
|
||||
self.ironic.node.list.return_value = [
|
||||
mock.Mock(uuid="ABCDEFGH"),
|
||||
]
|
||||
|
||||
self.node = mock.Mock(uuid="ABCDEFGH", properties={})
|
||||
self.ironic.node.get.return_value = self.node
|
||||
|
||||
# inspector mocks
|
||||
self.inspector = mock.MagicMock()
|
||||
inspector_patcher = mock.patch(
|
||||
'tripleo_common.actions.base.TripleOAction.'
|
||||
'_get_baremetal_introspection_client',
|
||||
return_value=self.inspector)
|
||||
self.mock_inspector = inspector_patcher.start()
|
||||
self.addCleanup(self.mock_inspector.stop)
|
||||
|
||||
self.inspector.get_data.return_value = {
|
||||
'inventory': {'disks': self.disks}
|
||||
}
|
||||
|
||||
def test_smallest(self):
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest')
|
||||
action.run()
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 1)
|
||||
root_device_args = self.ironic.node.update.call_args_list[0]
|
||||
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
|
||||
'value': {'wwn': 'wwn2'}},
|
||||
{'op': 'add', 'path': '/properties/local_gb',
|
||||
'value': 4}]
|
||||
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
|
||||
root_device_args)
|
||||
|
||||
def test_largest(self):
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='largest')
|
||||
action.run()
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 1)
|
||||
root_device_args = self.ironic.node.update.call_args_list[0]
|
||||
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
|
||||
'value': {'wwn': 'wwn3'}},
|
||||
{'op': 'add', 'path': '/properties/local_gb',
|
||||
'value': 20}]
|
||||
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
|
||||
root_device_args)
|
||||
|
||||
def test_no_overwrite(self):
|
||||
self.node.properties['root_device'] = {'foo': 'bar'}
|
||||
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest')
|
||||
action.run()
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 0)
|
||||
|
||||
def test_with_overwrite(self):
|
||||
self.node.properties['root_device'] = {'foo': 'bar'}
|
||||
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest',
|
||||
overwrite=True)
|
||||
action.run()
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 1)
|
||||
root_device_args = self.ironic.node.update.call_args_list[0]
|
||||
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
|
||||
'value': {'wwn': 'wwn2'}},
|
||||
{'op': 'add', 'path': '/properties/local_gb',
|
||||
'value': 4}]
|
||||
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
|
||||
root_device_args)
|
||||
|
||||
def test_minimum_size(self):
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest',
|
||||
minimum_size=10)
|
||||
action.run()
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 1)
|
||||
root_device_args = self.ironic.node.update.call_args_list[0]
|
||||
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
|
||||
'value': {'wwn': 'wwn0'}},
|
||||
{'op': 'add', 'path': '/properties/local_gb',
|
||||
'value': 10}]
|
||||
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
|
||||
root_device_args)
|
||||
|
||||
def test_bad_inventory(self):
|
||||
self.inspector.get_data.return_value = {}
|
||||
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest')
|
||||
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
|
||||
"Malformed introspection data",
|
||||
action.run)
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 0)
|
||||
|
||||
def test_no_disks(self):
|
||||
self.inspector.get_data.return_value = {
|
||||
'inventory': {
|
||||
'disks': [{'name': '/dev/sda', 'size': 1 * units.Gi}]
|
||||
}
|
||||
}
|
||||
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest')
|
||||
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
|
||||
"No suitable disks",
|
||||
action.run)
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 0)
|
||||
|
||||
def test_no_data(self):
|
||||
self.inspector.get_data.side_effect = (
|
||||
ironic_inspector_client.ClientError(mock.Mock()))
|
||||
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest')
|
||||
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
|
||||
"No introspection data",
|
||||
action.run)
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 0)
|
||||
|
||||
def test_no_wwn_and_serial(self):
|
||||
self.inspector.get_data.return_value = {
|
||||
'inventory': {
|
||||
'disks': [{'name': '/dev/sda', 'size': 10 * units.Gi}]
|
||||
}
|
||||
}
|
||||
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='smallest')
|
||||
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
|
||||
"Neither WWN nor serial number are known",
|
||||
action.run)
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 0)
|
||||
|
||||
def test_device_list(self):
|
||||
action = baremetal.ConfigureRootDeviceAction(
|
||||
node_uuid='MOCK_UUID',
|
||||
root_device='hda,sda,sdb,sdc')
|
||||
action.run()
|
||||
|
||||
self.assertEqual(self.ironic.node.update.call_count, 1)
|
||||
root_device_args = self.ironic.node.update.call_args_list[0]
|
||||
expected_patch = [{'op': 'add', 'path': '/properties/root_device',
|
||||
'value': {'wwn': 'wwn0'}},
|
||||
{'op': 'add', 'path': '/properties/local_gb',
|
||||
'value': 10}]
|
||||
self.assertEqual(mock.call('ABCDEFGH', expected_patch),
|
||||
root_device_args)
|
||||
|
||||
def test_device_list_not_found(self):
|
||||
action = baremetal.ConfigureRootDeviceAction(node_uuid='MOCK_UUID',
|
||||
root_device='hda')
|
||||
|
||||
self.assertRaisesRegexp(exception.RootDeviceDetectionError,
|
||||
"Cannot find a disk",
|
||||
action.run)
|
||||
self.assertEqual(self.ironic.node.update.call_count, 0)
|
||||
|
@ -267,3 +267,123 @@ workflows:
|
||||
message: <% $.get('message', '') %>
|
||||
execution: <% execution() %>
|
||||
introspected_nodes: <% $.get('introspected_nodes', []) %>
|
||||
|
||||
configure:
|
||||
description: Take a list of manageable nodes and update their boot configuration.
|
||||
|
||||
input:
|
||||
- node_uuids
|
||||
- queue_name: tripleo
|
||||
- kernel_name: bm-deploy-kernel
|
||||
- ramdisk_name: bm-deploy-ramdisk
|
||||
- instance_boot_option: null
|
||||
- root_device: null
|
||||
- root_device_minimum_size: 4
|
||||
- overwrite_root_device_hints: False
|
||||
|
||||
tasks:
|
||||
|
||||
configure_boot:
|
||||
on-success: configure_root_device
|
||||
on-error: set_status_failed_configure_boot
|
||||
with-items: node_uuid in <% $.node_uuids %>
|
||||
action: tripleo.baremetal.configure_boot node_uuid=<% $.node_uuid %> kernel_name=<% $.kernel_name %> ramdisk_name=<% $.ramdisk_name %> instance_boot_option=<% $.instance_boot_option %>
|
||||
publish:
|
||||
status: SUCCESS
|
||||
message: 'Successfully configured boot options.'
|
||||
|
||||
configure_root_device:
|
||||
on-success: send_message
|
||||
on-error: set_status_failed_configure_root_device
|
||||
with-items: node_uuid in <% $.node_uuids %>
|
||||
action: tripleo.baremetal.configure_root_device node_uuid=<% $.node_uuid %> root_device=<% $.root_device %> minimum_size=<% $.root_device_minimum_size %> overwrite=<% $.overwrite_root_device_hints %>
|
||||
publish:
|
||||
status: SUCCESS
|
||||
message: 'Successfully configured root device.'
|
||||
|
||||
set_status_failed_configure_boot:
|
||||
on-success: send_message
|
||||
publish:
|
||||
status: FAILED
|
||||
message: <% task(configure_boot).result %>
|
||||
|
||||
set_status_failed_configure_root_device:
|
||||
on-success: send_message
|
||||
publish:
|
||||
status: FAILED
|
||||
message: <% task(configure_root_device).result %>
|
||||
|
||||
send_message:
|
||||
action: zaqar.queue_post
|
||||
input:
|
||||
queue_name: <% $.queue_name %>
|
||||
messages:
|
||||
body:
|
||||
type: tripleo.baremetal.v1.configure
|
||||
payload:
|
||||
status: <% $.get('status', 'SUCCESS') %>
|
||||
message: <% $.get('message', '') %>
|
||||
execution: <% execution() %>
|
||||
|
||||
|
||||
configure_manageable_nodes:
|
||||
description: Update the boot configuration of all nodes in 'manageable' state.
|
||||
|
||||
input:
|
||||
- queue_name: tripleo
|
||||
- kernel_name: 'bm-deploy-kernel'
|
||||
- ramdisk_name: 'bm-deploy-ramdisk'
|
||||
- instance_boot_option: null
|
||||
- root_device: null
|
||||
- root_device_minimum_size: 4
|
||||
- overwrite_root_device_hints: False
|
||||
|
||||
tasks:
|
||||
|
||||
get_manageable_nodes:
|
||||
action: ironic.node_list maintenance=False associated=False
|
||||
on-success: configure_manageable
|
||||
on-error: set_status_failed_get_manageable_nodes
|
||||
publish:
|
||||
managed_nodes: <% task(get_manageable_nodes).result.where($.provision_state = 'manageable').uuid %>
|
||||
|
||||
configure_manageable:
|
||||
on-success: send_message
|
||||
on-error: set_status_failed_configure_manageable
|
||||
workflow: tripleo.baremetal.v1.configure
|
||||
input:
|
||||
node_uuids: <% $.managed_nodes %>
|
||||
queue_name: <% $.queue_name %>
|
||||
kernel_name: <% $.kernel_name %>
|
||||
ramdisk_name: <% $.ramdisk_name %>
|
||||
instance_boot_option: <% $.instance_boot_option %>
|
||||
root_device: <% $.root_device %>
|
||||
root_device_minimum_size: <% $.root_device_minimum_size %>
|
||||
overwrite_root_device_hints: <% $.overwrite_root_device_hints %>
|
||||
publish:
|
||||
message: 'Manageable nodes configured successfully.'
|
||||
|
||||
set_status_failed_configure_manageable:
|
||||
on-success: send_message
|
||||
publish:
|
||||
status: FAILED
|
||||
message: <% task(configure_manageable).result %>
|
||||
|
||||
set_status_failed_get_manageable_nodes:
|
||||
on-success: send_message
|
||||
publish:
|
||||
status: FAILED
|
||||
message: <% task(get_manageable_nodes).result %>
|
||||
|
||||
send_message:
|
||||
action: zaqar.queue_post
|
||||
input:
|
||||
queue_name: <% $.queue_name %>
|
||||
messages:
|
||||
body:
|
||||
type: tripleo.baremetal.v1.configure_manageable_nodes
|
||||
payload:
|
||||
status: <% $.get('status', 'SUCCESS') %>
|
||||
message: <% $.get('message', '') %>
|
||||
execution: <% execution() %>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user