From 91c4539bf3baff643ac9fe1ac460f9ce3dfaf59a Mon Sep 17 00:00:00 2001 From: Vladyslav Drok Date: Mon, 13 Jun 2016 14:17:23 +0300 Subject: [PATCH] Add create command to ironic client This patch adds "ironic create" and "openstack create" commands understanding JSON and YAML files, documentation for it will be added in a subsequent patch. Partial-bug: #1588339 Co-Authored-By: Lucas Alvares Gomes Change-Id: I9b6a6d41fabd240ace65e7ac8b965af81c1b3272 --- ironicclient/osc/v1/baremetal_create.py | 77 ++++ ironicclient/osc/v1/baremetal_node.py | 12 - .../unit/osc/v1/test_baremetal_create.py | 70 ++++ .../tests/unit/v1/test_create_resources.py | 389 ++++++++++++++++++ .../unit/v1/test_create_resources_shell.py | 31 ++ ironicclient/v1/create_resources.py | 272 ++++++++++++ ironicclient/v1/create_resources_shell.py | 28 ++ ironicclient/v1/shell.py | 2 + .../add-create-command-3df5efbbecc33276.yaml | 9 + requirements.txt | 2 + setup.cfg | 2 +- 11 files changed, 881 insertions(+), 13 deletions(-) create mode 100644 ironicclient/osc/v1/baremetal_create.py create mode 100644 ironicclient/tests/unit/osc/v1/test_baremetal_create.py create mode 100644 ironicclient/tests/unit/v1/test_create_resources.py create mode 100644 ironicclient/tests/unit/v1/test_create_resources_shell.py create mode 100644 ironicclient/v1/create_resources.py create mode 100644 ironicclient/v1/create_resources_shell.py create mode 100644 releasenotes/notes/add-create-command-3df5efbbecc33276.yaml diff --git a/ironicclient/osc/v1/baremetal_create.py b/ironicclient/osc/v1/baremetal_create.py new file mode 100644 index 000000000..1299c8890 --- /dev/null +++ b/ironicclient/osc/v1/baremetal_create.py @@ -0,0 +1,77 @@ +# 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. + +import argparse +import logging + +from ironicclient.common.i18n import _ +from ironicclient import exc +from ironicclient.osc.v1 import baremetal_node +from ironicclient.v1 import create_resources + + +class CreateBaremetal(baremetal_node.CreateBaremetalNode): + """Create resources from files or Register a new node (DEPRECATED). + + Create resources from files (by only specifying the files) or register + a new node by specifying one or more optional arguments (DEPRECATED, + use 'openstack baremetal node create' instead). + """ + + log = logging.getLogger(__name__ + ".CreateBaremetal") + + def get_description(self): + return ("Create resources from files (by only specifying the files) " + "or register a new node by specifying one or more optional " + "arguments (DEPRECATED, use 'openstack baremetal node create' " + "instead)") + + # TODO(vdrok): Remove support for new node creation in the 'P' cycle. + def get_parser(self, prog_name): + parser = super(CreateBaremetal, self).get_parser(prog_name) + # NOTE(vdrok): It is a workaround to allow --driver to be optional for + # openstack create command while creation of nodes via this command is + # not removed completely + parser = argparse.ArgumentParser(parents=[parser], + conflict_handler='resolve', + description=self.__doc__) + parser.add_argument( + '--driver', + metavar='', + help='Specify this and any other optional arguments if you want ' + 'to create a node only. Note that this is deprecated; please ' + 'use "openstack baremetal node create" instead.') + parser.add_argument( + "resource_files", metavar="", default=[], nargs="*", + help="File (.yaml or .json) containing descriptions of the " + "resources to create. Can be specified multiple times. If " + "you want to create resources, only specify the files. Do " + "not specify any of the optional arguments.") + return parser + + def take_action(self, parsed_args): + if parsed_args.driver: + self.log.warning("This command is deprecated. Instead, use " + "'openstack baremetal node create'.") + return super(CreateBaremetal, self).take_action(parsed_args) + if not parsed_args.resource_files: + raise exc.ValidationError(_( + "If --driver is not supplied to openstack create command, " + "it is considered that it will create ironic resources from " + "one or more .json or .yaml files, but no files provided.")) + create_resources.create_resources(self.app.client_manager.baremetal, + parsed_args.resource_files) + # NOTE(vdrok): CreateBaremetal is still inherited from ShowOne class, + # which requires the return value of the function to be of certain + # type, leave this workaround until creation of nodes is removed and + # then change it so that this inherits from command.Command + return tuple(), tuple() diff --git a/ironicclient/osc/v1/baremetal_node.py b/ironicclient/osc/v1/baremetal_node.py index 594d4b010..cc3a303d0 100644 --- a/ironicclient/osc/v1/baremetal_node.py +++ b/ironicclient/osc/v1/baremetal_node.py @@ -183,18 +183,6 @@ class CreateBaremetalNode(show.ShowOne): return self.dict2columns(node) -class CreateBaremetal(CreateBaremetalNode): - """Register a new node with the baremetal service. DEPRECATED""" - - # TODO(thrash): Remove in the 'P' cycle. - log = logging.getLogger(__name__ + ".CreateBaremetal") - - def take_action(self, parsed_args): - self.log.warning("This command is deprecated. Instead, use " - "'openstack baremetal node create'.") - return super(CreateBaremetal, self).take_action(parsed_args) - - class DeleteBaremetalNode(command.Command): """Unregister a baremetal node""" diff --git a/ironicclient/tests/unit/osc/v1/test_baremetal_create.py b/ironicclient/tests/unit/osc/v1/test_baremetal_create.py new file mode 100644 index 000000000..5b126f0b1 --- /dev/null +++ b/ironicclient/tests/unit/osc/v1/test_baremetal_create.py @@ -0,0 +1,70 @@ +# 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. +# + +import copy +import mock + +from ironicclient import exc +from ironicclient.osc.v1 import baremetal_create +from ironicclient.tests.unit.osc.v1 import fakes as baremetal_fakes +from ironicclient.v1 import create_resources + + +class TestBaremetalCreate(baremetal_fakes.TestBaremetal): + def setUp(self): + super(TestBaremetalCreate, self).setUp() + self.cmd = baremetal_create.CreateBaremetal(self.app, None) + + def test_baremetal_create_with_driver(self): + self.baremetal_mock = self.app.client_manager.baremetal + self.baremetal_mock.reset_mock() + self.baremetal_mock.node.create.return_value = ( + baremetal_fakes.FakeBaremetalResource( + None, + copy.deepcopy(baremetal_fakes.BAREMETAL), + loaded=True, + )) + + arglist = ['--driver', 'fake_driver'] + verifylist = [('driver', 'fake_driver')] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + self.assertEqual(('instance_uuid', 'maintenance', 'name', + 'power_state', 'provision_state', 'uuid'), columns) + self.assertEqual(('yyy-yyyyyy-yyyy', + baremetal_fakes.baremetal_maintenance, + baremetal_fakes.baremetal_name, + baremetal_fakes.baremetal_power_state, + baremetal_fakes.baremetal_provision_state, + baremetal_fakes.baremetal_uuid), tuple(data)) + self.baremetal_mock.node.create.assert_called_once_with( + driver='fake_driver') + + def test_baremetal_create_no_args(self): + parsed_args = self.check_parser(self.cmd, [], []) + self.assertRaises(exc.ValidationError, + self.cmd.take_action, parsed_args) + + @mock.patch.object(create_resources, 'create_resources', autospec=True) + def test_baremetal_create_resource_files(self, mock_create): + arglist = ['file.yaml', 'file.json'] + verifylist = [('resource_files', ['file.yaml', 'file.json'])] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + self.assertEqual((tuple(), tuple()), self.cmd.take_action(parsed_args)) + mock_create.assert_called_once_with(self.app.client_manager.baremetal, + ['file.yaml', 'file.json']) diff --git a/ironicclient/tests/unit/v1/test_create_resources.py b/ironicclient/tests/unit/v1/test_create_resources.py new file mode 100644 index 000000000..c560e78ff --- /dev/null +++ b/ironicclient/tests/unit/v1/test_create_resources.py @@ -0,0 +1,389 @@ +# 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. + +import jsonschema +import mock +import six.moves.builtins as __builtin__ + +from ironicclient import exc +from ironicclient.tests.unit import utils +from ironicclient.v1 import create_resources + +valid_json = { + "chassis": [{ + "description": "testing resources import", + "nodes": [{ + "driver": "agent_ssh", + "extra": { + "kv1": True, + "vk1": None + }, + "properties": { + "a": "c" + }, + "ports": [{ + "address": "00:00:00:00:00:01", + "extra": { + "a": "b" + } + }] + }] + }], + "nodes": [{ + "driver": "fake", + "driver_info": { + "fake_key": "fake", + "dict_prop": { + "a": "b" + }, + "arr_prop": [ + 1, 2, 3 + ] + }, + "chassis_uuid": "10f99593-b8c2-4fcb-8858-494c1a47cee6" + }] +} + +ironic_pov_invalid_json = { + "nodes": [{ + "driver": "non_existent", + "ports": [{ + "address": "invalid_address" + }] + }] +} + +schema_pov_invalid_json = {"meow": "woof!"} + + +class CreateSchemaTest(utils.BaseTestCase): + + def test_schema(self): + schema = create_resources._CREATE_SCHEMA + jsonschema.validate(valid_json, schema) + jsonschema.validate(ironic_pov_invalid_json, schema) + self.assertRaises(jsonschema.ValidationError, jsonschema.validate, + schema_pov_invalid_json, schema) + + +class CreateResourcesTest(utils.BaseTestCase): + + def setUp(self): + super(CreateResourcesTest, self).setUp() + self.client = mock.MagicMock() + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + @mock.patch.object(create_resources, 'create_chassis', autospec=True) + @mock.patch.object(jsonschema, 'validate', autospec=True) + @mock.patch.object(create_resources, 'load_from_file', + side_effect=[valid_json], autospec=True) + def test_create_resources( + self, mock_load, mock_validate, mock_chassis, mock_nodes): + resources_files = ['file.json'] + create_resources.create_resources(self.client, resources_files) + mock_load.assert_has_calls([ + mock.call('file.json') + ]) + mock_validate.assert_called_once_with(valid_json, mock.ANY) + mock_chassis.assert_called_once_with(self.client, + valid_json['chassis']) + mock_nodes.assert_called_once_with(self.client, + valid_json['nodes']) + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + @mock.patch.object(create_resources, 'create_chassis', autospec=True) + @mock.patch.object(jsonschema, 'validate', autospec=True) + @mock.patch.object(create_resources, 'load_from_file', + side_effect=exc.ClientException, autospec=True) + def test_create_resources_cannot_read_schema( + self, mock_load, mock_validate, mock_chassis, mock_nodes): + resources_files = ['file.json'] + self.assertRaises(exc.ClientException, + create_resources.create_resources, + self.client, resources_files) + mock_load.assert_called_once_with('file.json') + self.assertFalse(mock_validate.called) + self.assertFalse(mock_chassis.called) + self.assertFalse(mock_nodes.called) + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + @mock.patch.object(create_resources, 'create_chassis', autospec=True) + @mock.patch.object(jsonschema, 'validate', + side_effect=jsonschema.ValidationError(''), + autospec=True) + @mock.patch.object(create_resources, 'load_from_file', + side_effect=[schema_pov_invalid_json], autospec=True) + def test_create_resources_validation_fails( + self, mock_load, mock_validate, mock_chassis, mock_nodes): + resources_files = ['file.json'] + self.assertRaises(exc.ClientException, + create_resources.create_resources, + self.client, resources_files) + mock_load.assert_has_calls([ + mock.call('file.json') + ]) + mock_validate.assert_called_once_with(schema_pov_invalid_json, + mock.ANY) + self.assertFalse(mock_chassis.called) + self.assertFalse(mock_nodes.called) + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + @mock.patch.object(create_resources, 'create_chassis', autospec=True) + @mock.patch.object(jsonschema, 'validate', + side_effect=[None, jsonschema.ValidationError('')], + autospec=True) + @mock.patch.object(create_resources, 'load_from_file', + side_effect=[valid_json, schema_pov_invalid_json], + autospec=True) + def test_create_resources_validation_fails_multiple( + self, mock_load, mock_validate, mock_chassis, mock_nodes): + resources_files = ['file.json', 'file2.json'] + self.assertRaises(exc.ClientException, + create_resources.create_resources, + self.client, resources_files) + mock_load.assert_has_calls([ + mock.call('file.json'), mock.call('file2.json') + ]) + mock_validate.assert_has_calls([ + mock.call(valid_json, mock.ANY), + mock.call(schema_pov_invalid_json, mock.ANY) + ]) + self.assertFalse(mock_chassis.called) + self.assertFalse(mock_nodes.called) + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + @mock.patch.object(create_resources, 'create_chassis', autospec=True) + @mock.patch.object(jsonschema, 'validate', autospec=True) + @mock.patch.object(create_resources, 'load_from_file', + side_effect=[ironic_pov_invalid_json], autospec=True) + def test_create_resources_ironic_fails_to_create( + self, mock_load, mock_validate, mock_chassis, mock_nodes): + mock_nodes.return_value = [exc.ClientException('cannot create that')] + mock_chassis.return_value = [] + resources_files = ['file.json'] + self.assertRaises(exc.ClientException, + create_resources.create_resources, + self.client, resources_files) + mock_load.assert_has_calls([ + mock.call('file.json') + ]) + mock_validate.assert_called_once_with(ironic_pov_invalid_json, + mock.ANY) + mock_chassis.assert_called_once_with(self.client, []) + mock_nodes.assert_called_once_with( + self.client, ironic_pov_invalid_json['nodes']) + + +class LoadFromFileTest(utils.BaseTestCase): + + @mock.patch.object(__builtin__, 'open', + mock.mock_open(read_data='{"a": "b"}')) + def test_load_json(self): + fname = 'abc.json' + res = create_resources.load_from_file(fname) + self.assertEqual({'a': 'b'}, res) + + @mock.patch.object(__builtin__, 'open', + mock.mock_open(read_data='{"a": "b"}')) + def test_load_unknown_extension(self): + fname = 'abc' + self.assertRaisesRegex(exc.ClientException, + 'must have .json or .yaml extension', + create_resources.load_from_file, fname) + + @mock.patch.object(__builtin__, 'open') + def test_load_ioerror(self, mock_open): + mock_open.side_effect = IOError('file does not exist') + fname = 'abc.json' + self.assertRaisesRegex(exc.ClientException, + 'Cannot read file', + create_resources.load_from_file, fname) + + @mock.patch.object(__builtin__, 'open', + mock.mock_open(read_data='{{bbb')) + def test_load_incorrect_json(self): + fname = 'abc.json' + self.assertRaisesRegex( + exc.ClientException, 'File "%s" is invalid' % fname, + create_resources.load_from_file, fname) + + @mock.patch.object(__builtin__, 'open', + mock.mock_open(read_data='---\na: b')) + def test_load_yaml(self): + fname = 'abc.yaml' + res = create_resources.load_from_file(fname) + self.assertEqual({'a': 'b'}, res) + + @mock.patch.object(__builtin__, 'open', + mock.mock_open(read_data='---\na-: - b')) + def test_load_incorrect_yaml(self): + fname = 'abc.yaml' + self.assertRaisesRegex( + exc.ClientException, 'File "%s" is invalid' % fname, + create_resources.load_from_file, fname) + + +class CreateMethodsTest(utils.BaseTestCase): + + def setUp(self): + super(CreateMethodsTest, self).setUp() + self.client = mock.MagicMock() + + def test_create_single_node(self): + params = {'driver': 'fake'} + self.client.node.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual( + ('uuid', None), + create_resources.create_single_node(self.client, **params) + ) + self.client.node.create.assert_called_once_with(driver='fake') + + def test_create_single_node_with_ports(self): + params = {'driver': 'fake', 'ports': ['some ports here']} + self.client.node.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual( + ('uuid', None), + create_resources.create_single_node(self.client, **params) + ) + self.client.node.create.assert_called_once_with(driver='fake') + + def test_create_single_node_raises_client_exception(self): + params = {'driver': 'fake'} + e = exc.ClientException('foo') + self.client.node.create.side_effect = e + res, err = create_resources.create_single_node(self.client, **params) + self.assertEqual(None, res) + self.assertIsInstance(err, exc.ClientException) + self.assertIn('Unable to create the node', str(err)) + self.client.node.create.assert_called_once_with(driver='fake') + + def test_create_single_node_raises_invalid_exception(self): + params = {'driver': 'fake'} + e = exc.InvalidAttribute('foo') + self.client.node.create.side_effect = e + res, err = create_resources.create_single_node(self.client, **params) + self.assertEqual(None, res) + self.assertIsInstance(err, exc.InvalidAttribute) + self.assertIn('Cannot create the node with attributes', str(err)) + self.client.node.create.assert_called_once_with(driver='fake') + + def test_create_single_port(self): + params = {'address': 'fake-address', 'node_uuid': 'fake-node-uuid'} + self.client.port.create.return_value = mock.Mock(uuid='fake-port-uuid') + self.assertEqual( + ('fake-port-uuid', None), + create_resources.create_single_port(self.client, **params) + ) + self.client.port.create.assert_called_once_with(**params) + + def test_create_single_chassis(self): + self.client.chassis.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual( + ('uuid', None), + create_resources.create_single_chassis(self.client) + ) + self.client.chassis.create.assert_called_once_with() + + def test_create_single_chassis_with_nodes(self): + params = {'nodes': ['some nodes here']} + self.client.chassis.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual( + ('uuid', None), + create_resources.create_single_chassis(self.client, **params) + ) + self.client.chassis.create.assert_called_once_with() + + def test_create_ports(self): + port = {'address': 'fake-address'} + port_with_node_uuid = port.copy() + port_with_node_uuid.update(node_uuid='fake-node-uuid') + self.client.port.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual([], create_resources.create_ports(self.client, [port], + 'fake-node-uuid')) + self.client.port.create.assert_called_once_with(**port_with_node_uuid) + + def test_create_ports_two_node_uuids(self): + port = {'address': 'fake-address', 'node_uuid': 'node-uuid-1'} + self.client.port.create.return_value = mock.Mock(uuid='uuid') + errs = create_resources.create_ports(self.client, [port], + 'node-uuid-2') + self.assertIsInstance(errs[0], exc.ClientException) + self.assertEqual(1, len(errs)) + + @mock.patch.object(create_resources, 'create_ports', autospec=True) + def test_create_nodes(self, mock_create_ports): + node = {'driver': 'fake', 'ports': ['list of ports']} + self.client.node.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual([], create_resources.create_nodes(self.client, + [node])) + self.client.node.create.assert_called_once_with(driver='fake') + mock_create_ports.assert_called_once_with( + self.client, ['list of ports'], node_uuid='uuid') + + @mock.patch.object(create_resources, 'create_ports', autospec=True) + def test_create_nodes_exception(self, mock_create_ports): + node = {'driver': 'fake', 'ports': ['list of ports']} + self.client.node.create.side_effect = exc.ClientException('bar') + errs = create_resources.create_nodes(self.client, [node]) + self.assertIsInstance(errs[0], exc.ClientException) + self.assertEqual(1, len(errs)) + self.client.node.create.assert_called_once_with(driver='fake') + self.assertFalse(mock_create_ports.called) + + @mock.patch.object(create_resources, 'create_ports', autospec=True) + def test_create_nodes_two_chassis_uuids(self, mock_create_ports): + node = {'driver': 'fake', 'ports': ['list of ports'], + 'chassis_uuid': 'chassis-uuid-1'} + errs = create_resources.create_nodes(self.client, [node], + chassis_uuid='chassis-uuid-2') + self.assertFalse(self.client.node.create.called) + self.assertFalse(mock_create_ports.called) + self.assertEqual(1, len(errs)) + self.assertIsInstance(errs[0], exc.ClientException) + + @mock.patch.object(create_resources, 'create_ports', autospec=True) + def test_create_nodes_no_ports(self, mock_create_ports): + node = {'driver': 'fake'} + self.client.node.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual([], create_resources.create_nodes(self.client, + [node])) + self.client.node.create.assert_called_once_with(driver='fake') + self.assertFalse(mock_create_ports.called) + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + def test_create_chassis(self, mock_create_nodes): + chassis = {'description': 'fake', 'nodes': ['list of nodes']} + self.client.chassis.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual([], create_resources.create_chassis(self.client, + [chassis])) + self.client.chassis.create.assert_called_once_with(description='fake') + mock_create_nodes.assert_called_once_with( + self.client, ['list of nodes'], chassis_uuid='uuid') + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + def test_create_chassis_exception(self, mock_create_nodes): + chassis = {'description': 'fake', 'nodes': ['list of nodes']} + self.client.chassis.create.side_effect = exc.ClientException('bar') + errs = create_resources.create_chassis(self.client, [chassis]) + self.client.chassis.create.assert_called_once_with(description='fake') + self.assertFalse(mock_create_nodes.called) + self.assertEqual(1, len(errs)) + self.assertIsInstance(errs[0], exc.ClientException) + + @mock.patch.object(create_resources, 'create_nodes', autospec=True) + def test_create_chassis_no_nodes(self, mock_create_nodes): + chassis = {'description': 'fake'} + self.client.chassis.create.return_value = mock.Mock(uuid='uuid') + self.assertEqual([], create_resources.create_chassis(self.client, + [chassis])) + self.client.chassis.create.assert_called_once_with(description='fake') + self.assertFalse(mock_create_nodes.called) diff --git a/ironicclient/tests/unit/v1/test_create_resources_shell.py b/ironicclient/tests/unit/v1/test_create_resources_shell.py new file mode 100644 index 000000000..2fb6bea39 --- /dev/null +++ b/ironicclient/tests/unit/v1/test_create_resources_shell.py @@ -0,0 +1,31 @@ +# 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. + +import mock + +from ironicclient.tests.unit import utils +from ironicclient.v1 import create_resources +from ironicclient.v1 import create_resources_shell + + +class TestCreateResourcesShell(utils.BaseTestCase): + def setUp(self): + super(TestCreateResourcesShell, self).setUp() + self.client = mock.MagicMock(autospec=True) + + @mock.patch.object(create_resources, 'create_resources') + def test_create_shell(self, mock_create_resources): + args = mock.MagicMock() + files = ['file1', 'file2', 'file3'] + args.resource_files = [files] + create_resources_shell.do_create(self.client, args) + mock_create_resources.assert_called_once_with(self.client, files) diff --git a/ironicclient/v1/create_resources.py b/ironicclient/v1/create_resources.py new file mode 100644 index 000000000..fcda99d6f --- /dev/null +++ b/ironicclient/v1/create_resources.py @@ -0,0 +1,272 @@ +# 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. + +import functools +import json + +import jsonschema +import six +import yaml + +from ironicclient import exc + +_CREATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Schema for ironic resources file", + "type": "object", + "properties": { + "chassis": { + "type": "array", + "items": { + "type": "object" + } + }, + "nodes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "additionalProperties": False +} + + +def create_resources(client, filenames): + """Create resources using their JSON or YAML descriptions. + + :param client: an instance of ironic client; + :param filenames: a list of filenames containing JSON or YAML resources + definitions. + :raises: ClientException if any operation during files processing/resource + creation fails. + """ + errors = [] + resources = [] + for resource_file in filenames: + try: + resource = load_from_file(resource_file) + jsonschema.validate(resource, _CREATE_SCHEMA) + resources.append(resource) + except (exc.ClientException, jsonschema.ValidationError) as e: + errors.append(e) + if errors: + raise exc.ClientException('While validating the resources file(s), the' + ' following error(s) were encountered:\n%s' % + '\n'.join(six.text_type(e) for e in errors)) + for r in resources: + errors.extend(create_chassis(client, r.get('chassis', []))) + errors.extend(create_nodes(client, r.get('nodes', []))) + if errors: + raise exc.ClientException('During resources creation, the following ' + 'error(s) were encountered:\n%s' % + '\n'.join(six.text_type(e) for e in errors)) + + +def load_from_file(filename): + """Deserialize JSON or YAML from file. + + :param filename: name of the file containing JSON or YAML. + :returns: a dictionary deserialized from JSON or YAML. + :raises: ClientException if the file can not be loaded or if its contents + is not a valid JSON or YAML, or if the file extension is not supported. + """ + try: + with open(filename) as f: + if filename.endswith('.yaml'): + return yaml.load(f) + elif filename.endswith('.json'): + return json.load(f) + else: + # The file is neither .json, nor .yaml, raise an exception + raise exc.ClientException( + 'Cannot process file "%(file)s" - it must have .json or ' + '.yaml extension.' % {'file': filename}) + except IOError as e: + raise exc.ClientException('Cannot read file "%(file)s" due to ' + 'error: %(err)s' % + {'err': e, 'file': filename}) + except (ValueError, yaml.YAMLError) as e: + # json.load raises only ValueError + raise exc.ClientException('File "%(file)s" is invalid due to error: ' + '%(err)s' % {'err': e, 'file': filename}) + + +def create_single_handler(resource_type): + """Catch errors of the creation of a single resource. + + This decorator appends an error (which is an instance of some client + exception class) to the return value of the create_method, changing the + return value from just UUID to (UUID, error), and does some exception + handling. + + :param resource_type: string value, the type of the resource being created, + e.g. 'node', used purely for exception messages. + """ + + def outer_wrapper(create_method): + @functools.wraps(create_method) + def wrapper(client, **params): + uuid = None + error = None + try: + uuid = create_method(client, **params) + except exc.InvalidAttribute as e: + error = exc.InvalidAttribute( + 'Cannot create the %(resource)s with attributes ' + '%(params)s. One or more attributes are invalid: %(err)s' % + {'params': params, 'resource': resource_type, 'err': e} + ) + except Exception as e: + error = exc.ClientException( + 'Unable to create the %(resource)s with the specified ' + 'attributes: %(params)s. The error is: %(error)s' % + {'error': e, 'resource': resource_type, 'params': params}) + return uuid, error + return wrapper + return outer_wrapper + + +@create_single_handler('node') +def create_single_node(client, **params): + """Call the client to create a node. + + :param client: ironic client instance. + :param params: dictionary to be POSTed to /nodes endpoint, excluding + "ports" key. + :returns: UUID of the created node or None in case of exception, and an + exception, if it appears. + :raises: InvalidAttribute, if some parameters passed to client's + create_method are invalid. + :raises: ClientException, if the creation of the node fails. + """ + params.pop('ports', None) + ret = client.node.create(**params) + return ret.uuid + + +@create_single_handler('port') +def create_single_port(client, **params): + """Call the client to create a port. + + :param client: ironic client instance. + :param params: dictionary to be POSTed to /ports endpoint. + :returns: UUID of the created port or None in case of exception, and an + exception, if it appears. + :raises: InvalidAttribute, if some parameters passed to client's + create_method are invalid. + :raises: ClientException, if the creation of the port fails. + """ + ret = client.port.create(**params) + return ret.uuid + + +@create_single_handler('chassis') +def create_single_chassis(client, **params): + """Call the client to create a chassis. + + :param client: ironic client instance. + :param params: dictionary to be POSTed to /chassis endpoint, excluding + "nodes" key. + :returns: UUID of the created chassis or None in case of exception, and an + exception, if it appears. + :raises: InvalidAttribute, if some parameters passed to client's + create_method are invalid. + :raises: ClientException, if the creation of the chassis fails. + """ + params.pop('nodes', None) + ret = client.chassis.create(**params) + return ret.uuid + + +def create_ports(client, port_list, node_uuid): + """Create ports from dictionaries. + + :param client: ironic client instance. + :param port_list: list of dictionaries to be POSTed to /ports + endpoint. + :param node_uuid: UUID of a node the ports should be associated with. + :returns: array of exceptions encountered during creation. + """ + errors = [] + for port in port_list: + port_node_uuid = port.get('node_uuid') + if port_node_uuid and port_node_uuid != node_uuid: + errors.append(exc.ClientException( + 'Cannot create a port as part of node %(node_uuid)s ' + 'because the port %(port)s has a different node UUID ' + 'specified.', + {'node_uuid': node_uuid, + 'port': port})) + continue + port['node_uuid'] = node_uuid + port_uuid, error = create_single_port(client, **port) + if error: + errors.append(error) + return errors + + +def create_nodes(client, node_list, chassis_uuid=None): + """Create nodes from dictionaries. + + :param client: ironic client instance. + :param node_list: list of dictionaries to be POSTed to /nodes + endpoint, if some of them contain "ports" key, its content is POSTed + separately to /ports endpoint. + :param chassis_uuid: UUID of a chassis the nodes should be associated with. + :returns: array of exceptions encountered during creation. + """ + errors = [] + for node in node_list: + if chassis_uuid is not None: + node_chassis_uuid = node.get('chassis_uuid') + if node_chassis_uuid and node_chassis_uuid != chassis_uuid: + errors.append(exc.ClientException( + 'Cannot create a node as part of chassis %(chassis_uuid)s ' + 'because the node %(node)s has a different chassis UUID ' + 'specified.' % + {'chassis_uuid': chassis_uuid, + 'node': node})) + continue + node['chassis_uuid'] = chassis_uuid + node_uuid, error = create_single_node(client, **node) + if error: + errors.append(error) + ports = node.get('ports') + # Node UUID == None means that node creation failed, don't + # create the ports inside it + if ports is not None and node_uuid is not None: + errors.extend(create_ports(client, ports, node_uuid=node_uuid)) + return errors + + +def create_chassis(client, chassis_list): + """Create chassis from dictionaries. + + :param client: ironic client instance. + :param chassis_list: list of dictionaries to be POSTed to /chassis + endpoint, if some of them contain "nodes" key, its content is POSTed + separately to /nodes endpoint. + :returns: array of exceptions encountered during creation. + """ + errors = [] + for chassis in chassis_list: + chassis_uuid, error = create_single_chassis(client, **chassis) + if error: + errors.append(error) + nodes = chassis.get('nodes') + # Chassis UUID == None means that chassis creation failed, don't + # create the nodes inside it + if nodes is not None and chassis_uuid is not None: + errors.extend(create_nodes(client, nodes, + chassis_uuid=chassis_uuid)) + return errors diff --git a/ironicclient/v1/create_resources_shell.py b/ironicclient/v1/create_resources_shell.py new file mode 100644 index 000000000..9f0f435bc --- /dev/null +++ b/ironicclient/v1/create_resources_shell.py @@ -0,0 +1,28 @@ +# 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. + +from ironicclient.common import cliutils +from ironicclient.v1 import create_resources + + +@cliutils.arg('resource_files', nargs='+', metavar='', default=[], + help='File (.yaml or .json) containing descriptions of the ' + 'resources to create. Can be specified multiple times.') +def do_create(cc, args): + """Create baremetal resources (chassis, nodes, and ports). + + The resources may be described in one or more JSON or YAML files. If any + file cannot be validated, no resources are created. An attempt is made to + create all the resources; those that could not be created are skipped + (with a corresponding error message). + """ + create_resources.create_resources(cc, args.resource_files[0]) diff --git a/ironicclient/v1/shell.py b/ironicclient/v1/shell.py index 3b6cf4279..8e1a8be2a 100644 --- a/ironicclient/v1/shell.py +++ b/ironicclient/v1/shell.py @@ -13,6 +13,7 @@ from ironicclient.common import utils from ironicclient.v1 import chassis_shell +from ironicclient.v1 import create_resources_shell from ironicclient.v1 import driver_shell from ironicclient.v1 import node_shell from ironicclient.v1 import port_shell @@ -22,6 +23,7 @@ COMMAND_MODULES = [ node_shell, port_shell, driver_shell, + create_resources_shell, ] diff --git a/releasenotes/notes/add-create-command-3df5efbbecc33276.yaml b/releasenotes/notes/add-create-command-3df5efbbecc33276.yaml new file mode 100644 index 000000000..ca2ff6dcb --- /dev/null +++ b/releasenotes/notes/add-create-command-3df5efbbecc33276.yaml @@ -0,0 +1,9 @@ +--- +features: + - Added a new ``ironic create`` command that creates resources (chassis, + nodes, ports) specified in one or more JSON (``*.json``) or YAML + (``*.yaml``) files. Similar to the ``ironic create`` command, the + ``openstack baremetal create`` command creates resources specified in one + or more files. (Note that ``openstack baremetal create`` can also be used + to create a node via specified node attributes. However this feature is + deprecated.) diff --git a/requirements.txt b/requirements.txt index ca4cf9f16..627fba514 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,10 +5,12 @@ pbr>=1.6 # Apache-2.0 appdirs>=1.3.0 # MIT License dogpile.cache>=0.6.1 # BSD cliff!=1.16.0,!=1.17.0,>=1.15.0 # Apache-2.0 +jsonschema>=2.0.0,<3.0.0,!=2.5.0 # MIT keystoneauth1>=2.10.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 oslo.utils>=3.16.0 # Apache-2.0 PrettyTable<0.8,>=0.7 # BSD python-openstackclient>=2.1.0 # Apache-2.0 +PyYAML>=3.1.0 # MIT requests>=2.10.0 # Apache-2.0 six>=1.9.0 # MIT diff --git a/setup.cfg b/setup.cfg index 17ef4b0de..4c5db9428 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ openstack.cli.extension = baremetal = ironicclient.osc.plugin openstack.baremetal.v1 = - baremetal_create = ironicclient.osc.v1.baremetal_node:CreateBaremetal + baremetal_create = ironicclient.osc.v1.baremetal_create:CreateBaremetal baremetal_delete = ironicclient.osc.v1.baremetal_node:DeleteBaremetal baremetal_list = ironicclient.osc.v1.baremetal_node:ListBaremetal baremetal_node_abort = ironicclient.osc.v1.baremetal_node:AbortBaremetalNode