Add node register API: /v1/nodes/{ID}/register

This API does following:

* Creates a node in Ironic of driver type `redfish`
  and details like the redfish URL, username, password,
  system URL of node.
* Creates a port for the above node in Ironic.
* Updates the field `managed_by` to `ironic` in Valence db.

Change-Id: Ia81a2eb6ecb2b48efc3a8c99183d12bbc1635702
This commit is contained in:
Madhuri Kumari 2017-03-21 17:38:38 +00:00
parent a75dc523c8
commit e2cfdbac2e
26 changed files with 578 additions and 4 deletions

View File

@ -17,3 +17,6 @@ python-etcd>=0.4.3 # MIT License
oslo.utils>=3.20.0 # Apache-2.0
oslo.config>=3.22.0 # Apache-2.0
oslo.i18n>=2.1.0 # Apache-2.0
python-ironicclient>=1.11.0 # Apache-2.0
python-keystoneclient>=3.8.0 # Apache-2.0
stevedore>=1.20.0 # Apache-2.0

View File

@ -62,3 +62,6 @@ console_scripts =
oslo.config.opts =
valence = valence.opts:list_opts
valence.conf = valence.conf.opts:list_opts
valence.provision.driver =
ironic = valence.provision.ironic.driver:IronicDriver

View File

@ -83,6 +83,9 @@ api.add_resource(v1_nodes.NodeManage, '/v1/nodes/manage',
api.add_resource(v1_nodes.NodesStorage,
'/v1/nodes/<string:nodeid>/storages',
endpoint='nodes_storages')
api.add_resource(v1_nodes.NodeRegister,
'/v1/nodes/<string:node_uuid>/register',
endpoint='node_register')
# System(s) operations
api.add_resource(v1_systems.SystemsList, '/v1/systems', endpoint='systems')

View File

@ -67,3 +67,10 @@ class NodesStorage(Resource):
def get(self, nodeid):
return abort(http_client.NOT_IMPLEMENTED)
class NodeRegister(Resource):
def post(self, node_uuid):
return utils.make_response(http_client.OK, nodes.Node.node_register(
node_uuid, request.get_json()))

56
valence/common/clients.py Normal file
View File

@ -0,0 +1,56 @@
# Copyright 2017 Intel.
#
# 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 import client as ironicclient
from valence.common import exception
import valence.conf
CONF = valence.conf.CONF
class OpenStackClients(object):
"""Convenience class to create and cache client instances."""
def __init__(self, context=None):
self.context = context
self._ironic = None
def _get_client_option(self, client, option):
return getattr(getattr(valence.conf.CONF, '%s_client' % client),
option)
@exception.wrap_keystone_exception
def ironic(self):
if self._ironic:
return self._ironic
ironicclient_version = self._get_client_option('ironic', 'api_version')
args = {
'os_auth_url': self._get_client_option('ironic', 'auth_url'),
'os_username': self._get_client_option('ironic', 'username'),
'os_password': self._get_client_option('ironic', 'password'),
'os_project_name': self._get_client_option('ironic', 'project'),
'os_project_domain_id': self._get_client_option(
'ironic', 'project_domain_id'),
'os_user_domain_id': self._get_client_option(
'ironic', 'user_domain_id'),
'os_cacert': self._get_client_option('ironic', 'os_cacert'),
'os_cert': self._get_client_option('ironic', 'os_cert'),
'os_key': self._get_client_option('ironic', 'os_key'),
'insecure': self._get_client_option('ironic', 'insecure')
}
self._ironic = ironicclient.get_client(ironicclient_version, **args)
return self._ironic

View File

@ -12,6 +12,10 @@
# License for the specific language governing permissions and limitations
# under the License.
import functools
import sys
from keystoneclient import exceptions as keystone_exceptions
from six.moves import http_client
from valence.common import base
@ -70,6 +74,16 @@ class ValenceConfirmation(base.ObjectBase):
}
class ValenceException(ValenceError):
def __init__(self, detail, status=None,
request_id=FAKE_REQUEST_ID):
self.request_id = request_id
self.status = status or http_client.SERVICE_UNAVAILABLE
self.code = "ValenceError"
self.title = http_client.responses.get(self.status)
self.detail = detail
class RedfishException(ValenceError):
def __init__(self, responsejson, request_id=FAKE_REQUEST_ID,
@ -123,6 +137,13 @@ class ValidationError(BadRequest):
code='ValidationError')
class AuthorizationFailure(ValenceError):
def __init__(self, detail, request_id=None):
message = "Keystone authorization error. %s" % detail
super(AuthorizationFailure, self).__init__(detail=message,
code='AuthorizationFailure')
def _error(error_code, http_status, error_title, error_detail,
request_id=FAKE_REQUEST_ID):
# responseobj - the response object of Requests framework
@ -151,3 +172,21 @@ def confirmation(request_id=FAKE_REQUEST_ID, confirm_code='',
confirm_obj.code = confirm_code
confirm_obj.detail = confirm_detail
return confirm_obj.as_dict()
def wrap_keystone_exception(func):
"""Wrap keystone exceptions and throw Valence specific exceptions."""
@functools.wraps(func)
def wrapped(*args, **kw):
try:
return func(*args, **kw)
except keystone_exceptions.AuthorizationFailure:
message = ("%s connection failed. Reason: "
"%s" % (func.__name__, sys.exc_info()[1]))
raise AuthorizationFailure(detail=message)
except keystone_exceptions.ClientException:
message = ("%s connection failed. Unexpected keystone client "
"error occurred: %s" % (func.__name__,
sys.exc_info()[1]))
raise AuthorizationFailure(detail=message)
return wrapped

View File

@ -16,10 +16,12 @@ from oslo_config import cfg
from valence.conf import api
from valence.conf import etcd
from valence.conf import ironic_client
from valence.conf import podm
CONF = cfg.CONF
api.register_opts(CONF)
etcd.register_opts(CONF)
ironic_client.register_opts(CONF)
podm.register_opts(CONF)

View File

@ -0,0 +1,68 @@
# Copyright 2017 Intel.
#
# 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 oslo_config import cfg
from valence.common.i18n import _
ironic_group = cfg.OptGroup(name='ironic_client',
title='Options for the Ironic client')
common_security_opts = [
cfg.StrOpt('os_cacert',
help=_('Optional CA cert file to use in SSL connections.')),
cfg.StrOpt('os_cert',
help=_('Optional PEM-formatted certificate chain file.')),
cfg.StrOpt('os_key',
help=_('Optional PEM-formatted file that contains the '
'private key.')),
cfg.BoolOpt('insecure',
default=False,
help=_("If set, then the server's certificate will not "
"be verified."))]
ironic_client_opts = [
cfg.StrOpt('username',
help=_('The name of user to interact with Ironic API '
'service.')),
cfg.StrOpt('password',
help=_('Password of the user specified to authorize to '
'communicate with the Ironic API service.')),
cfg.StrOpt('project',
help=_('The project name which the user belongs to.')),
cfg.StrOpt('auth_url',
help=_('The OpenStack Identity Service endpoint to authorize '
'the user against.')),
cfg.StrOpt('user_domain_id',
help=_(
'ID of a domain the user belongs to.')),
cfg.StrOpt('project_domain_id',
help=_(
'ID of a domain the project belongs to.')),
cfg.StrOpt('api_version',
default='1',
help=_('Version of Ironic API to use in ironicclient.'))]
ALL_OPTS = (ironic_client_opts + common_security_opts)
def register_opts(conf):
conf.register_group(ironic_group)
conf.register_opts(ALL_OPTS, group=ironic_group)
def list_opts():
return {ironic_group: ALL_OPTS}

View File

@ -20,6 +20,7 @@ from valence.common import exception
from valence.common import utils
from valence.controller import flavors
from valence.db import api as db_api
from valence.provision import driver
from valence.redfish import redfish
LOG = logging.getLogger(__name__)
@ -194,3 +195,14 @@ class Node(object):
# Get node detail from db, and map node uuid to index
index = db_api.Connection.get_composed_node_by_uuid(node_uuid).index
return redfish.node_action(index, request_body)
@classmethod
def node_register(cls, node_uuid, request_body):
"""Register a node to provisioning services.
:param node_uuid: UUID of composed node to register
:param request_body: parameter of register node with
:returns: response from provisioning services
"""
resp = driver.node_register(node_uuid, request_body)
return resp

View File

@ -20,6 +20,7 @@ import etcd
from oslo_utils import uuidutils
import six
from valence.common import exception
from valence.common import singleton
import valence.conf
from valence.db import models
@ -167,7 +168,7 @@ class EtcdDriver(object):
except etcd.EtcdKeyNotFound:
# TODO(lin.a.yang): after exception module got merged, raise
# valence specific DBNotFound exception here
raise Exception(
raise exception.NotFound(
'Composed node not found {0} in database.'.format(
composed_node_uuid))

View File

@ -207,5 +207,8 @@ class ComposedNode(ModelBaseWithTimeStamp):
},
'links': {
'validate': types.List(types.Dict).validate
},
'managed_by': {
'validate': types.Text.validate
}
}

View File

View File

@ -0,0 +1,69 @@
# Copyright 2017 Intel.
#
# 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 abc
import logging
import stevedore
from valence.common import exception
LOG = logging.getLogger(__name__)
def load_driver(driver='ironic'):
"""Load an provisioning driver module.
Load the provisioning driver module specified by the driver
configuration option or, if supplied, the driver name supplied as an
argument.
:param driver: provisioning driver name to override config opt
:returns: a ProvisioningDriver instance
"""
LOG.info("Loading provisioning driver '%s'" % driver)
try:
driver = stevedore.driver.DriverManager(
"valence.provision.driver",
driver,
invoke_on_load=True).driver
if not isinstance(driver, ProvisioningDriver):
raise Exception('Expected driver of type: %s' %
str(ProvisioningDriver))
return driver
except Exception:
LOG.exception("Unable to load the provisioning driver")
raise exception.ValenceException("Failed to load %s driver" % driver)
def node_register(node, param):
driver = load_driver()
return driver.node_register(node, param)
class ProvisioningDriver(object):
'''Base class for provisioning driver.
'''
@abc.abstractmethod
def register(self, node_uuid, param=None):
"""Register a node."""
raise NotImplementedError()
@abc.abstractmethod
def deregister(self, node_uuid):
"""Unregister a node."""
raise NotImplementedError()

View File

View File

@ -0,0 +1,80 @@
# Copyright 2017 Intel.
#
# 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 logging
import six
from valence.common import exception
import valence.conf
from valence.controller import nodes
from valence.db import api as db_api
from valence.provision import driver
from valence.provision.ironic import utils
CONF = valence.conf.CONF
LOG = logging.getLogger(__name__)
class IronicDriver(driver.ProvisioningDriver):
def __init__(self):
super(IronicDriver, self).__init__()
def node_register(self, node_uuid, param):
LOG.debug('Registering node %s with ironic' % node_uuid)
node_info = nodes.Node.get_composed_node_by_uuid(node_uuid)
try:
ironic = utils.create_ironicclient()
except Exception as e:
message = ('Error occurred while communicating to '
'Ironic: %s' % six.text_type(e))
LOG.error(message)
raise exception.ValenceException(message)
try:
# NOTE(mkrai): Below implementation will be changed in future to
# support the multiple pod manager in which we access pod managers'
# detail from podm object associated with a node.
driver_info = {
'redfish_address': CONF.podm.url,
'redfish_username': CONF.podm.username,
'redfish_password': CONF.podm.password,
'redfish_verify_ca': CONF.podm.verify_ca,
'redfish_system_id': node_info['computer_system']}
node_args = {}
if param:
if param.get('driver_info', None):
driver_info.update(param.get('driver_info'))
del param['driver_info']
node_args.update({'driver': 'redfish', 'name': node_info['name'],
'driver_info': driver_info})
if param:
node_args.update(param)
ironic_node = ironic.node.create(**node_args)
port_args = {'node_uuid': ironic_node.uuid,
'address': node_info['metadata']['network'][0]['mac']}
ironic.port.create(**port_args)
db_api.Connection.update_composed_node(node_uuid,
{'managed_by': 'ironic'})
return exception.confirmation(
confirm_code="Node Registered",
confirm_detail="The composed node {0} has been registered "
"with Ironic successfully.".format(node_uuid))
except Exception as e:
message = ('Unexpected error while registering node with '
'Ironic: %s' % six.text_type(e))
LOG.error(message)
raise exception.ValenceException(message)

View File

@ -0,0 +1,25 @@
# Copyright 2017 Intel.
#
# 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 valence.common import clients
def create_ironicclient():
"""Creates ironic client object.
:returns: Ironic client object
"""
osc = clients.OpenStackClients()
return osc.ironic()

View File

@ -456,7 +456,8 @@ def get_node_by_id(node_index, show_detail=True):
"network": [show_network_details(i.get("@odata.id")) for i in
respdata.get("Links", {}).get(
"EthernetInterfaces", [])]
}
},
"computer_system": respdata.get("Links").get("ComputerSystem")
})
return node_detail

View File

@ -42,6 +42,7 @@ class TestRoute(unittest.TestCase):
self.assertEqual(self.api.owns_endpoint('nodes'), True)
self.assertEqual(self.api.owns_endpoint('node'), True)
self.assertEqual(self.api.owns_endpoint('nodes_storages'), True)
self.assertEqual(self.api.owns_endpoint('node_register'), True)
self.assertEqual(self.api.owns_endpoint('systems'), True)
self.assertEqual(self.api.owns_endpoint('system'), True)
self.assertEqual(self.api.owns_endpoint('flavors'), True)

View File

@ -0,0 +1,54 @@
# 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 unittest
import mock
from ironicclient import client as ironicclient
from valence.common import clients
import valence.conf
class ClientsTest(unittest.TestCase):
def setUp(self):
super(ClientsTest, self).setUp()
valence.conf.CONF.set_override('auth_url',
'http://server.test:5000/v2.0',
group='ironic_client')
valence.conf.CONF.set_override('api_version', 1,
group='ironic_client')
@mock.patch.object(ironicclient, 'get_client')
def test_clients_ironic(self, mock_client):
obj = clients.OpenStackClients()
obj._ironic = None
obj.ironic()
mock_client.assert_called_once_with(
valence.conf.CONF.ironic_client.api_version,
os_auth_url='http://server.test:5000/v2.0', os_username=None,
os_project_name=None,
os_project_domain_id=None,
os_user_domain_id=None,
os_password=None, os_cacert=None, os_cert=None,
os_key=None, insecure=False)
@mock.patch.object(ironicclient, 'get_client')
def test_clients_ironic_cached(self, mock_client):
obj = clients.OpenStackClients()
obj._ironic = None
ironic = obj.ironic()
ironic_cached = obj.ironic()
self.assertEqual(ironic, ironic_cached)

View File

@ -236,3 +236,8 @@ class TestAPINodes(unittest.TestCase):
nodes.Node.node_action("fake_uuid", action)
mock_node_action.assert_called_once_with("1", action)
@mock.patch("valence.provision.driver.node_register")
def test_node_register(self, mock_node_register):
nodes.Node.node_register("fake_uuid", {"foo": "bar"})
mock_node_register.assert_called_once_with("fake_uuid", {"foo": "bar"})

View File

@ -18,6 +18,7 @@ import etcd
import freezegun
import mock
from valence.common import exception
from valence.db import api as db_api
from valence.tests.unit.db import utils
@ -216,11 +217,11 @@ class TestDBAPI(unittest.TestCase):
node = utils.get_test_composed_node_db_info()
mock_etcd_read.side_effect = etcd.EtcdKeyNotFound
with self.assertRaises(Exception) as context: # noqa: H202
with self.assertRaises(exception.NotFound) as context: # noqa: H202
db_api.Connection.get_composed_node_by_uuid(node['uuid'])
self.assertTrue('Composed node not found {0} in database.'.format(
node['uuid']) in str(context.exception))
node['uuid']) in str(context.exception.detail))
mock_etcd_read.assert_called_once_with(
'/nodes/' + node['uuid'])

View File

View File

@ -0,0 +1,72 @@
# Copyright 2016 Intel.
#
# 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 oslotest import base
from valence.common import exception
from valence.provision.ironic import driver
class TestDriver(base.BaseTestCase):
def setUp(self):
super(TestDriver, self).setUp()
self.ironic = driver.IronicDriver()
def tearDown(self):
super(TestDriver, self).tearDown()
@mock.patch("valence.controller.nodes.Node.get_composed_node_by_uuid")
def test_node_register_node_not_found(self, mock_db):
mock_db.side_effect = exception.NotFound
self.assertRaises(exception.NotFound,
self.ironic.node_register,
'fake-uuid', {})
@mock.patch("valence.controller.nodes.Node.get_composed_node_by_uuid")
@mock.patch("valence.provision.ironic.utils.create_ironicclient")
def test_node_register_ironic_client_failure(self, mock_client,
mock_db):
mock_client.side_effect = Exception()
self.assertRaises(exception.ValenceException,
self.ironic.node_register,
'fake-uuid', {})
@mock.patch("valence.db.api.Connection.update_composed_node")
@mock.patch("valence.controller.nodes.Node.get_composed_node_by_uuid")
@mock.patch("valence.provision.ironic.utils.create_ironicclient")
def test_node_register(self, mock_client,
mock_node_get, mock_node_update):
ironic = mock.MagicMock()
mock_client.return_value = ironic
mock_node_get.return_value = {
'name': 'test', 'metadata':
{'network': [{'mac': 'fake-mac'}]},
'computer_system': '/redfish/v1/Systems/437XR1138R2'}
ironic.node.create.return_value = mock.MagicMock(uuid='ironic-uuid')
port_arg = {'node_uuid': 'ironic-uuid', 'address': 'fake-mac'}
resp = self.ironic.node_register('fake-uuid',
{"extra": {"foo": "bar"}})
self.assertEqual({
'code': 'Node Registered',
'detail': 'The composed node fake-uuid has been '
'registered with Ironic successfully.',
'request_id': '00000000-0000-0000-0000-000000000000'}, resp)
mock_client.assert_called_once()
mock_node_get.assert_called_once_with('fake-uuid')
mock_node_update.assert_called_once_with('fake-uuid',
{'managed_by': 'ironic'})
ironic.node.create.assert_called_once()
ironic.port.create.assert_called_once_with(**port_arg)

View File

@ -0,0 +1,29 @@
# copyright (c) 2017 Intel, Inc.
#
# 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 oslotest import base
from valence.provision.ironic import utils
class TestUtils(base.BaseTestCase):
def setUp(self):
super(TestUtils, self).setUp()
@mock.patch('valence.common.clients.OpenStackClients.ironic')
def test_create_ironicclient(self, mock_ironic):
ironic = utils.create_ironicclient()
self.assertTrue(ironic)
mock_ironic.assert_called_once_with()

View File

@ -0,0 +1,40 @@
# Copyright 2017 Intel.
#
# 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 oslotest import base
from valence.common import exception
import valence.conf
from valence.provision import driver
CONF = valence.conf.CONF
class TestDriver(base.BaseTestCase):
def setUp(self):
super(TestDriver, self).setUp()
def test_load_driver_failure(self):
self.assertRaises(exception.ValenceException, driver.load_driver,
'UnknownDriver')
def test_load_driver(self):
self.assertTrue(driver.load_driver, 'ironic.IronicDriver')
@mock.patch("valence.provision.driver.load_driver")
def test_node_register(self, mock_driver):
driver.node_register('fake-uuid', {})
mock_driver.assert_called_once()