Add 'node-set-provision-state <node> clean'

This adds the manual cleaning CLI:

usage: ironic node-set-provision-state [--config-drive <config-drive>]
                                       [--clean-steps <clean-steps>]
                                       <node> <provision-state>

where <provision-state> is 'clean' and <clean-steps> is
the clean steps in JSON format. May be the path to a file containing
the clean steps; OR '-', with the clean steps being read from
standard input; OR a string. The value should be a list of clean-step
dictionaries; each dictionary should have keys 'interface' and
'step', and optional key 'args'. This argument must be specified
(and is only valid) when setting provision-state to 'clean'.

Change-Id: Id5907812935096c5c557c896daf72175ed138866
Partial-Bug: #1526290
Depends-On: I0e34407133684e34c4ab9446b3521a24f3038f92
This commit is contained in:
Ruby Loo 2015-12-16 22:27:20 +00:00 committed by Ruby Loo
parent db175f917c
commit e6f6d9eacc
5 changed files with 294 additions and 13 deletions

View File

@ -849,6 +849,17 @@ class NodeManagerTest(testtools.TestCase):
]
self.assertEqual(expect, self.api.calls)
def test_node_set_provision_state_with_cleansteps(self):
cleansteps = [{"step": "upgrade", "interface": "deploy"}]
target_state = 'clean'
self.mgr.set_provision_state(NODE1['uuid'], target_state,
cleansteps=cleansteps)
body = {'target': target_state, 'clean_steps': cleansteps}
expect = [
('PUT', '/v1/nodes/%s/states/provision' % NODE1['uuid'], {}, body),
]
self.assertEqual(expect, self.api.calls)
def test_node_states(self):
states = self.mgr.states(NODE1['uuid'])
expect = [

View File

@ -12,11 +12,17 @@
# License for the specific language governing permissions and limitations
# under the License.
import json
import sys
import tempfile
import mock
import six.moves.builtins as __builtin__
from ironicclient.common.apiclient import exceptions
from ironicclient.common import cliutils
from ironicclient.common import utils as commonutils
from ironicclient import exc
from ironicclient.tests.unit import utils
import ironicclient.v1.node_shell as n_shell
@ -385,10 +391,11 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'active'
args.config_drive = 'foo'
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'active', configdrive='foo')
'node_uuid', 'active', configdrive='foo', cleansteps=None)
def test_do_node_set_provision_state_deleted(self):
client_mock = mock.MagicMock()
@ -396,10 +403,11 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'deleted'
args.config_drive = None
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'deleted', configdrive=None)
'node_uuid', 'deleted', configdrive=None, cleansteps=None)
def test_do_node_set_provision_state_rebuild(self):
client_mock = mock.MagicMock()
@ -407,10 +415,11 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'rebuild'
args.config_drive = None
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'rebuild', configdrive=None)
'node_uuid', 'rebuild', configdrive=None, cleansteps=None)
def test_do_node_set_provision_state_not_active_fails(self):
client_mock = mock.MagicMock()
@ -418,6 +427,7 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'deleted'
args.config_drive = 'foo'
args.clean_steps = None
self.assertRaises(exceptions.CommandError,
n_shell.do_node_set_provision_state,
@ -430,10 +440,11 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'inspect'
args.config_drive = None
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'inspect', configdrive=None)
'node_uuid', 'inspect', configdrive=None, cleansteps=None)
def test_do_node_set_provision_state_manage(self):
client_mock = mock.MagicMock()
@ -441,10 +452,11 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'manage'
args.config_drive = None
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'manage', configdrive=None)
'node_uuid', 'manage', configdrive=None, cleansteps=None)
def test_do_node_set_provision_state_provide(self):
client_mock = mock.MagicMock()
@ -452,10 +464,108 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'provide'
args.config_drive = None
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'provide', configdrive=None)
'node_uuid', 'provide', configdrive=None, cleansteps=None)
def test_do_node_set_provision_state_clean(self):
client_mock = mock.MagicMock()
args = mock.MagicMock()
args.node = 'node_uuid'
args.provision_state = 'clean'
args.config_drive = None
clean_steps = '[{"step": "upgrade", "interface": "deploy"}]'
args.clean_steps = clean_steps
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'clean', configdrive=None,
cleansteps=json.loads(clean_steps))
@mock.patch.object(n_shell, '_get_from_stdin', autospec=True)
def test_do_node_set_provision_state_clean_stdin(self, mock_stdin):
clean_steps = '[{"step": "upgrade", "interface": "deploy"}]'
mock_stdin.return_value = clean_steps
client_mock = mock.MagicMock()
args = mock.MagicMock()
args.node = 'node_uuid'
args.provision_state = 'clean'
args.config_drive = None
args.clean_steps = '-'
n_shell.do_node_set_provision_state(client_mock, args)
mock_stdin.assert_called_once_with('clean steps')
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'clean', configdrive=None,
cleansteps=json.loads(clean_steps))
@mock.patch.object(n_shell, '_get_from_stdin', autospec=True)
def test_do_node_set_provision_state_clean_stdin_fails(self, mock_stdin):
mock_stdin.side_effect = exc.InvalidAttribute('bad')
client_mock = mock.MagicMock()
args = mock.MagicMock()
args.node = 'node_uuid'
args.provision_state = 'clean'
args.config_drive = None
args.clean_steps = '-'
self.assertRaises(exc.InvalidAttribute,
n_shell.do_node_set_provision_state,
client_mock, args)
mock_stdin.assert_called_once_with('clean steps')
self.assertFalse(client_mock.node.set_provision_state.called)
def test_do_node_set_provision_state_clean_file(self):
contents = '[{"step": "upgrade", "interface": "deploy"}]'
with tempfile.NamedTemporaryFile(mode='w') as f:
f.write(contents)
f.flush()
client_mock = mock.MagicMock()
args = mock.MagicMock()
args.node = 'node_uuid'
args.provision_state = 'clean'
args.config_drive = None
args.clean_steps = f.name
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'clean', configdrive=None,
cleansteps=json.loads(contents))
def test_do_node_set_provision_state_clean_fails(self):
client_mock = mock.MagicMock()
args = mock.MagicMock()
args.node = 'node_uuid'
args.provision_state = 'clean'
args.config_drive = None
args.clean_steps = None
# clean_steps isn't specified
self.assertRaisesRegex(exceptions.CommandError,
'clean-steps.*must be specified',
n_shell.do_node_set_provision_state,
client_mock, args)
self.assertFalse(client_mock.node.set_provision_state.called)
def test_do_node_set_provision_state_not_clean_fails(self):
client_mock = mock.MagicMock()
args = mock.MagicMock()
args.node = 'node_uuid'
args.provision_state = 'deleted'
args.config_drive = None
clean_steps = '[{"step": "upgrade", "interface": "deploy"}]'
args.clean_steps = clean_steps
# clean_steps specified but not cleaning
self.assertRaisesRegex(exceptions.CommandError,
'clean-steps.*only valid',
n_shell.do_node_set_provision_state,
client_mock, args)
self.assertFalse(client_mock.node.set_provision_state.called)
def test_do_node_set_provision_state_abort(self):
client_mock = mock.MagicMock()
@ -463,10 +573,11 @@ class NodeShellTest(utils.BaseTestCase):
args.node = 'node_uuid'
args.provision_state = 'abort'
args.config_drive = None
args.clean_steps = None
n_shell.do_node_set_provision_state(client_mock, args)
client_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'abort', configdrive=None)
'node_uuid', 'abort', configdrive=None, cleansteps=None)
def test_do_node_set_console_mode(self):
client_mock = mock.MagicMock()
@ -808,3 +919,60 @@ class NodeShellTest(utils.BaseTestCase):
n_shell.do_node_get_vendor_passthru_methods(client_mock, args)
client_mock.node.get_vendor_passthru_methods.assert_called_once_with(
'node_uuid')
class NodeShellLocalTest(utils.BaseTestCase):
@mock.patch.object(sys, 'stdin', autospec=True)
def test__get_from_stdin(self, mock_stdin):
contents = '[{"step": "upgrade", "interface": "deploy"}]'
mock_stdin.read.return_value = contents
desc = 'something'
info = n_shell._get_from_stdin(desc)
self.assertEqual(info, contents)
mock_stdin.read.assert_called_once_with()
@mock.patch.object(sys, 'stdin', autospec=True)
def test__get_from_stdin_fail(self, mock_stdin):
mock_stdin.read.side_effect = IOError
desc = 'something'
self.assertRaises(exc.InvalidAttribute, n_shell._get_from_stdin, desc)
mock_stdin.read.assert_called_once_with()
def test__handle_clean_steps_arg(self):
cleansteps = '[{"step": "upgrade", "interface": "deploy"}]'
steps = n_shell._handle_clean_steps_arg(cleansteps)
self.assertEqual(json.loads(cleansteps), steps)
def test__handle_clean_steps_arg_bad_json(self):
cleansteps = 'foo'
self.assertRaisesRegex(exc.InvalidAttribute,
'For clean steps',
n_shell._handle_clean_steps_arg, cleansteps)
def test__handle_clean_steps_arg_file(self):
contents = '[{"step": "upgrade", "interface": "deploy"}]'
with tempfile.NamedTemporaryFile(mode='w') as f:
f.write(contents)
f.flush()
steps = n_shell._handle_clean_steps_arg(f.name)
self.assertEqual(json.loads(contents), steps)
@mock.patch.object(__builtin__, 'open', autospec=True)
def test__handle_clean_steps_arg_file_fail(self, mock_open):
mock_file_object = mock.MagicMock()
mock_file_handle = mock.MagicMock()
mock_file_handle.__enter__.return_value = mock_file_object
mock_open.return_value = mock_file_handle
mock_file_object.read.side_effect = IOError
with tempfile.NamedTemporaryFile(mode='w') as f:
self.assertRaisesRegex(exc.InvalidAttribute,
"from file",
n_shell._handle_clean_steps_arg, f.name)
mock_open.assert_called_once_with(f.name, 'r')
mock_file_object.read.assert_called_once_with()

View File

@ -259,7 +259,26 @@ class NodeManager(base.CreateManager):
path = "%s/validate" % node_uuid
return self.get(path)
def set_provision_state(self, node_uuid, state, configdrive=None):
def set_provision_state(self, node_uuid, state, configdrive=None,
cleansteps=None):
"""Set the provision state for the node.
:param node_uuid: The UUID or name of the node.
:param state: The desired provision state. One of 'active', 'deleted',
'rebuild', 'inspect', 'provide', 'manage', 'clean', 'abort'.
:param configdrive: A gzipped, base64-encoded configuration drive
string OR the path to the configuration drive file OR the path to
a directory containing the config drive files. In case it's a
directory, a config drive will be generated from it. This is only
valid when setting state to 'active'.
:param cleansteps: The clean steps as a list of clean-step
dictionaries; each dictionary should have keys 'interface' and
'step', and optional key 'args'. This must be specified (and is
only valid) when setting provision-state to 'clean'.
:raises: InvalidAttribute if there was an error with the clean steps
:returns: The status of the request
"""
path = "%s/states/provision" % node_uuid
body = {'target': state}
if configdrive:
@ -270,6 +289,9 @@ class NodeManager(base.CreateManager):
configdrive = utils.make_configdrive(configdrive)
body['configdrive'] = configdrive
elif cleansteps:
body['clean_steps'] = cleansteps
return self.update(path, body, http_method='PUT')
def states(self, node_uuid):

View File

@ -14,11 +14,15 @@
# under the License.
import argparse
import json
import os
import sys
from ironicclient.common.apiclient import exceptions
from ironicclient.common import cliutils
from ironicclient.common.i18n import _
from ironicclient.common import utils
from ironicclient import exc
from ironicclient.v1 import resource_fields as res_fields
@ -31,6 +35,48 @@ def _print_node_show(node, fields=None):
cliutils.print_dict(data, wrap=72)
def _get_from_stdin(info_desc):
"""Read information from stdin.
:param info_desc: A string description of the desired information
:raises: InvalidAttribute if there was a problem reading from stdin
:returns: the string that was read from stdin
"""
try:
info = sys.stdin.read().strip()
except Exception as e:
err = _("Cannot get %(desc)s from standard input. Error: %(err)s")
raise exc.InvalidAttribute(err % {'desc': info_desc, 'err': e})
return info
def _handle_clean_steps_arg(clean_steps):
"""Attempts to read clean steps argument.
:param clean_steps: May be a file name containing the clean steps, or
a JSON string representing the clean steps.
:returns: A list of dictionaries representing clean steps.
:raises: InvalidAttribute if the argument cannot be parsed.
"""
if os.path.isfile(clean_steps):
try:
with open(clean_steps, 'r') as f:
clean_steps = f.read().strip()
except Exception as e:
err = _("Cannot get clean steps from file '%(file)s'. "
"Error: %(err)s") % {'err': e, 'file': clean_steps}
raise exc.InvalidAttribute(err)
try:
clean_steps = json.loads(clean_steps)
except ValueError as e:
err = (_("For clean steps: '%(steps)s', error: '%(err)s'") %
{'err': e, 'steps': clean_steps})
raise exc.InvalidAttribute(err)
return clean_steps
@cliutils.arg(
'node',
metavar='<id>',
@ -381,9 +427,9 @@ def do_node_set_power_state(cc, args):
'provision_state',
metavar='<provision-state>',
choices=['active', 'deleted', 'rebuild', 'inspect', 'provide',
'manage', 'abort'],
'manage', 'clean', 'abort'],
help="Supported states: 'active', 'deleted', 'rebuild', "
"'inspect', 'provide', 'manage' or 'abort'")
"'inspect', 'provide', 'manage', 'clean' or 'abort'.")
@cliutils.arg(
'--config-drive',
metavar='<config-drive>',
@ -391,15 +437,39 @@ def do_node_set_power_state(cc, args):
help=("A gzipped, base64-encoded configuration drive string OR the path "
"to the configuration drive file OR the path to a directory "
"containing the config drive files. In case it's a directory, a "
"config drive will be generated from it. This parameter is only "
"valid when setting provision state to 'active'."))
"config drive will be generated from it. This argument is only "
"valid when setting provision-state to 'active'."))
@cliutils.arg(
'--clean-steps',
metavar='<clean-steps>',
default=None,
help=("The clean steps in JSON format. May be the path to a file "
"containing the clean steps; OR '-', with the clean steps being "
"read from standard input; OR a string. The value should be "
"a list of clean-step dictionaries; each dictionary should have "
"keys 'interface' and 'step', and optional key 'args'. "
"This argument must be specified (and is only valid) when "
"setting provision-state to 'clean'."))
def do_node_set_provision_state(cc, args):
"""Initiate a provisioning state change for a node."""
if args.config_drive and args.provision_state != 'active':
raise exceptions.CommandError(_('--config-drive is only valid when '
'setting provision state to "active"'))
elif args.clean_steps and args.provision_state != 'clean':
raise exceptions.CommandError(_('--clean-steps is only valid when '
'setting provision state to "clean"'))
elif args.provision_state == 'clean' and not args.clean_steps:
raise exceptions.CommandError(_('--clean-steps must be specified when '
'setting provision state to "clean"'))
clean_steps = args.clean_steps
if args.clean_steps == '-':
clean_steps = _get_from_stdin('clean steps')
if clean_steps:
clean_steps = _handle_clean_steps_arg(clean_steps)
cc.node.set_provision_state(args.node, args.provision_state,
configdrive=args.config_drive)
configdrive=args.config_drive,
cleansteps=clean_steps)
@cliutils.arg('node', metavar='<node>', help="Name or UUID of the node.")

View File

@ -0,0 +1,10 @@
---
features:
- Adds support for manual cleaning API; available with ironic-api-version
1.15 or higher. The ironic CLI is "ironic node-set-provision-state
--clean-steps <clean-steps> <node> <provision-state>"
where <provision-state> is 'clean' and <clean-steps> is the clean steps
in JSON format. May be the path to a file containing the clean steps;
OR '-', with the clean steps being read from standard input; OR a string.
The value should be a list of clean-step dictionaries; each dictionary
should have keys 'interface' and 'step', and optional key 'args'.