Merge "Add create command to ironic client"
This commit is contained in:
commit
6680463a10
77
ironicclient/osc/v1/baremetal_create.py
Normal file
77
ironicclient/osc/v1/baremetal_create.py
Normal file
@ -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='<driver>',
|
||||
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="<file>", 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()
|
@ -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"""
|
||||
|
||||
|
70
ironicclient/tests/unit/osc/v1/test_baremetal_create.py
Normal file
70
ironicclient/tests/unit/osc/v1/test_baremetal_create.py
Normal file
@ -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'])
|
389
ironicclient/tests/unit/v1/test_create_resources.py
Normal file
389
ironicclient/tests/unit/v1/test_create_resources.py
Normal file
@ -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)
|
31
ironicclient/tests/unit/v1/test_create_resources_shell.py
Normal file
31
ironicclient/tests/unit/v1/test_create_resources_shell.py
Normal file
@ -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)
|
272
ironicclient/v1/create_resources.py
Normal file
272
ironicclient/v1/create_resources.py
Normal file
@ -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
|
28
ironicclient/v1/create_resources_shell.py
Normal file
28
ironicclient/v1/create_resources_shell.py
Normal file
@ -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='<file>', 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])
|
@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
@ -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.)
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user