diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index b697b64bfd..9bfaf8bf36 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -26,6 +26,7 @@ from ironic.common import exception from ironic.common import utils from ironic.db import api from ironic.db.sqlalchemy import models +from ironic.objects import node from ironic.openstack.common.db.sqlalchemy import session as db_session from ironic.openstack.common import log from ironic.openstack.common import uuidutils @@ -81,15 +82,19 @@ class Connection(api.Connection): def __init__(self): pass + @node.objectify def get_nodes(self, columns): pass + @node.objectify def get_associated_nodes(self): pass + @node.objectify def get_unassociated_nodes(self): pass + @node.objectify def reserve_nodes(self, tag, nodes): # Ensure consistent sort order so we don't run into deadlocks. nodes.sort() @@ -143,12 +148,14 @@ class Connection(api.Connection): if ref['reservation'] is not None: raise exception.NodeLocked(node=node) + @node.objectify def create_node(self, values): node = models.Node() node.update(values) node.save() return node + @node.objectify def get_node(self, node): query = model_query(models.Node) query = add_uuid_filter(query, node) @@ -160,6 +167,7 @@ class Connection(api.Connection): return result + @node.objectify def get_node_by_instance(self, instance): query = model_query(models.Node) if uuidutils.is_uuid_like(instance): @@ -184,6 +192,7 @@ class Connection(api.Connection): if count != 1: raise exception.NodeNotFound(node=node) + @node.objectify def update_node(self, node, values): session = get_session() with session.begin(): diff --git a/ironic/objects/node.py b/ironic/objects/node.py new file mode 100644 index 0000000000..7d366b5e56 --- /dev/null +++ b/ironic/objects/node.py @@ -0,0 +1,103 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# coding=utf-8 +# +# +# 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 ironic.db import api as db_api +from ironic.objects import base +from ironic.objects import utils + + +def objectify(fn): + """Decorator to convert database results into Node objects.""" + def wrapper(*args, **kwargs): + result = fn(*args, **kwargs) + try: + return Node._from_db_object(Node(), result) + except TypeError: + # TODO(deva): handle lists of objects better + # once support for those lands and is imported. + return [Node._from_db_object(Node(), obj) for obj in result] + return wrapper + + +class Node(base.IronicObject): + + dbapi = db_api.get_instance() + + fields = { + 'id': int, + + 'uuid': utils.str_or_none, + 'chassis_id': utils.int_or_none, + 'instance_uuid': utils.str_or_none, + + # NOTE(deva): should driver_info be a nested_object_or_none, + # or does this bind the driver API too tightly? + 'driver': utils.str_or_none, + 'driver_info': utils.str_or_none, + + 'properties': utils.str_or_none, + 'reservation': utils.str_or_none, + 'task_state': utils.str_or_none, + 'task_start': utils.datetime_or_none, + 'extra': utils.str_or_none, + } + + @staticmethod + def _from_db_object(node, db_node): + """Converts a database entity to a formal object.""" + for field in node.fields: + node[field] = db_node[field] + + node.obj_reset_changes() + return node + + @base.remotable_classmethod + def get_by_uuid(cls, context, uuid): + """Find a node based on uuid and return a Node object. + + :param uuid: the uuid of a node. + :returns: a :class:`Node` object. + """ + # TODO(deva): enable getting ports for this node + db_node = cls.dbapi.get_node(uuid) + return Node._from_db_object(cls(), db_node) + + @base.remotable + def save(self, context): + """Save updates to this Node. + + Column-wise updates will be made based on the result of + self.what_changed(). If expected_task_state is provided, + it will be checked against the in-database copy of the + node before updates are made. + + :param context: Security context + """ + updates = {} + changes = self.obj_what_changed() + for field in changes: + updates[field] = self[field] + self.dbapi.update_node(self.uuid, updates) + + self.obj_reset_changes() + + @base.remotable + def refresh(self, context): + current = self.__class__.get_by_uuid(context, uuid=self.uuid) + for field in self.fields: + if (hasattr(self, base.get_attrname(field)) and + self[field] != current[field]): + self[field] = current[field] diff --git a/ironic/tests/db/utils.py b/ironic/tests/db/utils.py index 06ccb1ebdb..c0861b879c 100644 --- a/ironic/tests/db/utils.py +++ b/ironic/tests/db/utils.py @@ -68,20 +68,22 @@ properties = json.dumps( def get_test_node(**kw): - node = models.Node() - - node.id = kw.get('id', 123) - node.uuid = kw.get('uuid', '1be26c0b-03f2-4d2e-ae87-c02d7f33c123') - node.task_state = kw.get('task_state', 'NOSTATE') - node.instance_uuid = kw.get('instance_uuid', - '8227348d-5f1d-4488-aad1-7c92b2d42504') - - node.driver = kw.get('driver', 'fake') - node.driver_info = kw.get('driver_info', fake_info) - - node.properties = kw.get('properties', properties) - node.extra = kw.get('extra', '{}') - + node = { + 'id': kw.get('id', 123), + 'uuid': kw.get('uuid', '1be26c0b-03f2-4d2e-ae87-c02d7f33c123'), + 'chassis_id': 5, + 'task_start': None, + 'task_state': kw.get('task_state', 'NOSTATE'), + 'instance_uuid': kw.get('instance_uuid', + '8227348d-5f1d-4488-aad1-7c92b2d42504'), + 'driver': kw.get('driver', 'fake'), + 'driver_info': kw.get('driver_info', fake_info), + 'properties': kw.get('properties', properties), + 'reservation': None, + 'extra': kw.get('extra', '{}'), + 'updated_at': None, + 'created_at': None, + } return node diff --git a/ironic/tests/drivers/test_ipmi.py b/ironic/tests/drivers/test_ipmi.py index a6370e6a29..45fcb7f20c 100644 --- a/ironic/tests/drivers/test_ipmi.py +++ b/ironic/tests/drivers/test_ipmi.py @@ -197,7 +197,7 @@ class IPMIDriverTestCase(db_base.DbTestCase): ipmi._power_on(self.info).AndReturn(states.POWER_ON) self.mox.ReplayAll() - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.driver.power.set_power_state( task, self.node, states.POWER_ON) self.mox.VerifyAll() @@ -210,7 +210,7 @@ class IPMIDriverTestCase(db_base.DbTestCase): ipmi._power_off(self.info).AndReturn(states.POWER_OFF) self.mox.ReplayAll() - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.driver.power.set_power_state( task, self.node, states.POWER_OFF) self.mox.VerifyAll() @@ -224,7 +224,7 @@ class IPMIDriverTestCase(db_base.DbTestCase): ipmi._power_on(self.info).AndReturn(states.ERROR) self.mox.ReplayAll() - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.assertRaises(exception.PowerStateFailure, self.driver.power.set_power_state, task, @@ -233,7 +233,7 @@ class IPMIDriverTestCase(db_base.DbTestCase): self.mox.VerifyAll() def test_set_power_invalid_state(self): - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.assertRaises(exception.IronicException, self.driver.power.set_power_state, task, @@ -247,12 +247,12 @@ class IPMIDriverTestCase(db_base.DbTestCase): AndReturn([None, None]) self.mox.ReplayAll() - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.driver.power._set_boot_device(task, self.node, 'pxe') self.mox.VerifyAll() def test_set_boot_device_bad_device(self): - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.assertRaises(exception.InvalidParameterValue, self.driver.power._set_boot_device, task, @@ -267,7 +267,7 @@ class IPMIDriverTestCase(db_base.DbTestCase): ipmi._power_on(self.info).AndReturn(states.POWER_ON) self.mox.ReplayAll() - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.driver.power.reboot(task, self.node) self.mox.VerifyAll() @@ -280,7 +280,7 @@ class IPMIDriverTestCase(db_base.DbTestCase): ipmi._power_on(self.info).AndReturn(states.ERROR) self.mox.ReplayAll() - with task_manager.acquire([self.node.uuid]) as task: + with task_manager.acquire([self.node['uuid']]) as task: self.assertRaises(exception.PowerStateFailure, self.driver.power.reboot, task, diff --git a/ironic/tests/objects/test_node.py b/ironic/tests/objects/test_node.py new file mode 100644 index 0000000000..aeffa8a21e --- /dev/null +++ b/ironic/tests/objects/test_node.py @@ -0,0 +1,106 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# coding=utf-8 +# +# +# 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 ironic.common import context +from ironic.db import api as db_api +from ironic.db.sqlalchemy import models +from ironic.objects import node +from ironic.tests.db import base +from ironic.tests.db import utils + + +class TestNodeObject(base.DbTestCase): + + def setUp(self): + super(TestNodeObject, self).setUp() + self.fake_node = utils.get_test_node() + self.dbapi = db_api.get_instance() + + def test_load(self): + ctxt = context.get_admin_context() + uuid = self.fake_node['uuid'] + self.mox.StubOutWithMock(self.dbapi, 'get_node') + + self.dbapi.get_node(uuid).AndReturn(self.fake_node) + self.mox.ReplayAll() + + node.Node.get_by_uuid(ctxt, uuid) + self.mox.VerifyAll() + # TODO(deva): add tests for load-on-demand info, eg. ports, + # once Port objects are created + + def test_save(self): + ctxt = context.get_admin_context() + uuid = self.fake_node['uuid'] + self.mox.StubOutWithMock(self.dbapi, 'get_node') + self.mox.StubOutWithMock(self.dbapi, 'update_node') + + self.dbapi.get_node(uuid).AndReturn(self.fake_node) + self.dbapi.update_node(uuid, {'properties': "new property"}) + self.mox.ReplayAll() + + n = node.Node.get_by_uuid(ctxt, uuid) + n.properties = "new property" + n.save() + self.mox.VerifyAll() + + def test_refresh(self): + ctxt = context.get_admin_context() + uuid = self.fake_node['uuid'] + self.mox.StubOutWithMock(self.dbapi, 'get_node') + + self.dbapi.get_node(uuid).AndReturn( + dict(self.fake_node, properties="first")) + self.dbapi.get_node(uuid).AndReturn( + dict(self.fake_node, properties="second")) + self.mox.ReplayAll() + + n = node.Node.get_by_uuid(ctxt, uuid) + self.assertEqual(n.properties, "first") + n.refresh() + self.assertEqual(n.properties, "second") + self.mox.VerifyAll() + + def test_objectify(self): + def _get_db_node(): + n = models.Node() + n.update(self.fake_node) + return n + + @node.objectify + def _convert_db_node(): + return _get_db_node() + + self.assertIsInstance(_get_db_node(), models.Node) + self.assertIsInstance(_convert_db_node(), node.Node) + + def test_objectify_many(self): + def _get_db_nodes(): + nodes = [] + for i in xrange(5): + n = models.Node() + n.update(self.fake_node) + nodes.append(n) + return nodes + + @node.objectify + def _convert_db_nodes(): + return _get_db_nodes() + + for n in _get_db_nodes(): + self.assertIsInstance(n, models.Node) + for n in _convert_db_nodes(): + self.assertIsInstance(n, node.Node)