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:
parent
db175f917c
commit
e6f6d9eacc
|
@ -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 = [
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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'.
|
Loading…
Reference in New Issue