diff --git a/nova/exception.py b/nova/exception.py index 837c2a5ecc..d0c182ce90 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -2098,3 +2098,7 @@ class RealtimeMaskNotFoundOrInvalid(Invalid): class OsInfoNotFound(NotFound): msg_fmt = _("No configuration information found for operating system " "%(os_name)s") + + +class BuildRequestNotFound(NotFound): + msg_fmt = _("BuildRequest not found for instance %(uuid)s") diff --git a/nova/objects/__init__.py b/nova/objects/__init__.py index e6f92f7878..be4aa0e563 100644 --- a/nova/objects/__init__.py +++ b/nova/objects/__init__.py @@ -28,6 +28,7 @@ def register_all(): __import__('nova.objects.aggregate') __import__('nova.objects.bandwidth_usage') __import__('nova.objects.block_device') + __import__('nova.objects.build_request') __import__('nova.objects.cell_mapping') __import__('nova.objects.compute_node') __import__('nova.objects.dns_domain') diff --git a/nova/objects/build_request.py b/nova/objects/build_request.py new file mode 100644 index 0000000000..8766520815 --- /dev/null +++ b/nova/objects/build_request.py @@ -0,0 +1,162 @@ +# 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 oslo_log import log as logging +from oslo_serialization import jsonutils +import six + +from nova.db.sqlalchemy import api as db +from nova.db.sqlalchemy import api_models +from nova import exception +from nova.i18n import _LE +from nova import objects +from nova.objects import base +from nova.objects import fields + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + +OBJECT_FIELDS = ['info_cache', 'security_groups'] +JSON_FIELDS = ['instance_metadata'] +IP_FIELDS = ['access_ip_v4', 'access_ip_v6'] + + +@base.NovaObjectRegistry.register +class BuildRequest(base.NovaObject): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'id': fields.IntegerField(), + 'project_id': fields.StringField(), + 'user_id': fields.StringField(), + 'display_name': fields.StringField(nullable=True), + 'instance_metadata': fields.DictOfStringsField(nullable=True), + 'progress': fields.IntegerField(nullable=True), + 'vm_state': fields.StringField(nullable=True), + 'task_state': fields.StringField(nullable=True), + 'image_ref': fields.StringField(nullable=True), + 'access_ip_v4': fields.IPV4AddressField(nullable=True), + 'access_ip_v6': fields.IPV6AddressField(nullable=True), + 'info_cache': fields.ObjectField('InstanceInfoCache', nullable=True), + 'security_groups': fields.ObjectField('SecurityGroupList'), + 'config_drive': fields.BooleanField(default=False), + 'key_name': fields.StringField(nullable=True), + 'locked_by': fields.EnumField(['owner', 'admin'], nullable=True), + 'request_spec': fields.ObjectField('RequestSpec'), + # NOTE(alaski): Normally these would come from the NovaPersistentObject + # mixin but they're being set explicitly because we only need + # created_at/updated_at. There is no soft delete for this object. + # These fields should be carried over to the instance when it is + # scheduled and created in a cell database. + 'created_at': fields.DateTimeField(nullable=True), + 'updated_at': fields.DateTimeField(nullable=True), + } + + def _load_request_spec(self, db_spec): + self.request_spec = objects.RequestSpec._from_db_object(self._context, + objects.RequestSpec(), db_spec) + + def _load_info_cache(self, db_info_cache): + self.info_cache = objects.InstanceInfoCache.obj_from_primitive( + jsonutils.loads(db_info_cache)) + + def _load_security_groups(self, db_sec_group): + self.security_groups = objects.SecurityGroupList.obj_from_primitive( + jsonutils.loads(db_sec_group)) + + @staticmethod + def _from_db_object(context, req, db_req): + for key in req.fields: + if isinstance(req.fields[key], fields.ObjectField): + try: + getattr(req, '_load_%s' % key)(db_req[key]) + except AttributeError: + LOG.exception(_LE('No load handler for %s'), key) + elif key in JSON_FIELDS and db_req[key] is not None: + setattr(req, key, jsonutils.loads(db_req[key])) + else: + setattr(req, key, db_req[key]) + req.obj_reset_changes() + req._context = context + return req + + @staticmethod + @db.api_context_manager.reader + def _get_by_instance_uuid_from_db(context, instance_uuid): + db_req = (context.session.query(api_models.BuildRequest) + .join(api_models.RequestSpec) + .with_entities(api_models.BuildRequest, + api_models.RequestSpec) + .filter( + api_models.RequestSpec.instance_uuid == instance_uuid) + ).first() + if not db_req: + raise exception.BuildRequestNotFound(uuid=instance_uuid) + # db_req is a tuple (api_models.BuildRequest, api_models.RequestSpect) + build_req = db_req[0] + build_req['request_spec'] = db_req[1] + return build_req + + @base.remotable_classmethod + def get_by_instance_uuid(cls, context, instance_uuid): + db_req = cls._get_by_instance_uuid_from_db(context, instance_uuid) + return cls._from_db_object(context, cls(), db_req) + + @staticmethod + @db.api_context_manager.writer + def _create_in_db(context, updates): + db_req = api_models.BuildRequest() + db_req.update(updates) + db_req.save(context.session) + # NOTE: This is done because a later access will trigger a lazy load + # outside of the db session so it will fail. We don't lazy load + # request_spec on the object later because we never need a BuildRequest + # without the RequestSpec. + db_req.request_spec + return db_req + + def _get_update_primitives(self): + updates = self.obj_get_changes() + for key, value in six.iteritems(updates): + if key in OBJECT_FIELDS and value is not None: + updates[key] = jsonutils.dumps(value.obj_to_primitive()) + elif key in JSON_FIELDS and value is not None: + updates[key] = jsonutils.dumps(value) + elif key in IP_FIELDS and value is not None: + # These are stored as a string in the db and must be converted + updates[key] = str(value) + req_spec_obj = updates.pop('request_spec', None) + if req_spec_obj: + updates['request_spec_id'] = req_spec_obj.id + return updates + + @base.remotable + def create(self): + if self.obj_attr_is_set('id'): + raise exception.ObjectActionError(action='create', + reason='already created') + + updates = self._get_update_primitives() + db_req = self._create_in_db(self._context, updates) + self._from_db_object(self._context, self, db_req) + + @staticmethod + @db.api_context_manager.writer + def _destroy_in_db(context, id): + context.session.query(api_models.BuildRequest).filter_by( + id=id).delete() + + @base.remotable + def destroy(self): + self._destroy_in_db(self._context, self.id) diff --git a/nova/tests/functional/db/test_build_request.py b/nova/tests/functional/db/test_build_request.py new file mode 100644 index 0000000000..94dff88518 --- /dev/null +++ b/nova/tests/functional/db/test_build_request.py @@ -0,0 +1,78 @@ +# 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_serialization import jsonutils +from oslo_utils import uuidutils + +from nova import context +from nova import exception +from nova import objects +from nova.objects import build_request +from nova import test +from nova.tests import fixtures +from nova.tests.unit import fake_build_request +from nova.tests.unit import fake_request_spec + + +class BuildRequestTestCase(test.NoDBTestCase): + def setUp(self): + super(BuildRequestTestCase, self).setUp() + # NOTE: This means that we're using a database for this test suite + # despite inheriting from NoDBTestCase + self.useFixture(fixtures.Database(database='api')) + self.context = context.RequestContext('fake-user', 'fake-project') + self.build_req_obj = build_request.BuildRequest() + self.instance_uuid = uuidutils.generate_uuid() + self.project_id = 'fake-project' + + def _create_req(self): + req_spec = fake_request_spec.fake_spec_obj(remove_id=True) + req_spec.instance_uuid = self.instance_uuid + req_spec.create() + args = fake_build_request.fake_db_req( + request_spec_id=req_spec.id) + args.pop('id', None) + args.pop('request_spec', None) + args['project_id'] = self.project_id + return build_request.BuildRequest._from_db_object(self.context, + self.build_req_obj, + self.build_req_obj._create_in_db(self.context, args)) + + def test_get_by_instance_uuid_not_found(self): + self.assertRaises(exception.BuildRequestNotFound, + self.build_req_obj._get_by_instance_uuid_from_db, self.context, + self.instance_uuid) + + def test_get_by_uuid(self): + req = self._create_req() + db_req = self.build_req_obj._get_by_instance_uuid_from_db(self.context, + self.instance_uuid) + for key in self.build_req_obj.fields.keys(): + expected = getattr(req, key) + db_value = db_req[key] + if key == 'request_spec': + # NOTE: The object and db value can't be compared directly as + # objects, so serialize them to a comparable form. + db_value = jsonutils.dumps(objects.RequestSpec._from_db_object( + self.context, objects.RequestSpec(), + db_value).obj_to_primitive()) + expected = jsonutils.dumps(expected.obj_to_primitive()) + elif key in build_request.OBJECT_FIELDS: + expected = jsonutils.dumps(expected.obj_to_primitive()) + elif key in build_request.JSON_FIELDS: + expected = jsonutils.dumps(expected) + elif key in build_request.IP_FIELDS: + expected = str(expected) + elif key in ['created_at', 'updated_at']: + # Objects store tz aware datetimes but the db does not. + expected = expected.replace(tzinfo=None) + self.assertEqual(expected, db_value) diff --git a/nova/tests/unit/fake_build_request.py b/nova/tests/unit/fake_build_request.py new file mode 100644 index 0000000000..bd31a9767a --- /dev/null +++ b/nova/tests/unit/fake_build_request.py @@ -0,0 +1,112 @@ +# 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 datetime + +from oslo_serialization import jsonutils +from oslo_utils import uuidutils + +from nova.compute import task_states +from nova.compute import vm_states +from nova import context +from nova.network import model as network_model +from nova import objects +from nova.objects import fields +from nova.tests.unit import fake_request_spec + + +def _req_spec_to_db_format(req_spec): + db_spec = {'spec': jsonutils.dumps(req_spec.obj_to_primitive()), + 'id': req_spec.id, + 'instance_uuid': req_spec.instance_uuid, + } + return db_spec + + +def fake_db_req(**updates): + instance_uuid = uuidutils.generate_uuid() + info_cache = objects.InstanceInfoCache() + info_cache.instance_uuid = instance_uuid + info_cache.network_info = network_model.NetworkInfo() + req_spec = fake_request_spec.fake_spec_obj( + context.RequestContext('fake-user', 'fake-project')) + req_spec.id = 42 + req_spec.obj_reset_changes() + db_build_request = { + 'id': 1, + 'project_id': 'fake-project', + 'user_id': 'fake-user', + 'display_name': '', + 'instance_metadata': jsonutils.dumps({'foo': 'bar'}), + 'progress': 0, + 'vm_state': vm_states.BUILDING, + 'task_state': task_states.SCHEDULING, + 'image_ref': None, + 'access_ip_v4': '1.2.3.4', + 'access_ip_v6': '::1', + 'info_cache': jsonutils.dumps(info_cache.obj_to_primitive()), + 'security_groups': jsonutils.dumps( + objects.SecurityGroupList().obj_to_primitive()), + 'config_drive': False, + 'key_name': None, + 'locked_by': None, + 'request_spec': _req_spec_to_db_format(req_spec), + 'created_at': datetime.datetime(2016, 1, 16), + 'updated_at': datetime.datetime(2016, 1, 16), + } + + for name, field in objects.BuildRequest.fields.items(): + if name in db_build_request: + continue + if field.nullable: + db_build_request[name] = None + elif field.default != fields.UnspecifiedDefault: + db_build_request[name] = field.default + else: + raise Exception('fake_db_req needs help with %s' % name) + + if updates: + db_build_request.update(updates) + + return db_build_request + + +def fake_req_obj(context, db_req=None): + if db_req is None: + db_req = fake_db_req() + req_obj = objects.BuildRequest(context) + for field in req_obj.fields: + value = db_req[field] + # create() can't be called if this is set + if field == 'id': + continue + if isinstance(req_obj.fields[field], fields.ObjectField): + value = value + if field == 'request_spec': + req_spec = objects.RequestSpec._from_db_object(context, + objects.RequestSpec(), value) + req_obj.request_spec = req_spec + elif field == 'info_cache': + setattr(req_obj, field, + objects.InstanceInfoCache.obj_from_primitive( + jsonutils.loads(value))) + elif field == 'security_groups': + setattr(req_obj, field, + objects.SecurityGroupList.obj_from_primitive( + jsonutils.loads(value))) + elif field == 'instance_metadata': + setattr(req_obj, field, jsonutils.loads(value)) + else: + setattr(req_obj, field, value) + # This should never be a changed field + req_obj.obj_reset_changes(['id']) + return req_obj diff --git a/nova/tests/unit/objects/test_build_request.py b/nova/tests/unit/objects/test_build_request.py new file mode 100644 index 0000000000..6e20b62251 --- /dev/null +++ b/nova/tests/unit/objects/test_build_request.py @@ -0,0 +1,81 @@ +# 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 nova import exception +from nova import objects +from nova.objects import build_request +from nova.tests.unit import fake_build_request +from nova.tests.unit.objects import test_objects + + +class _TestBuildRequestObject(object): + + @mock.patch.object(build_request.BuildRequest, + '_get_by_instance_uuid_from_db') + def test_get_by_instance_uuid(self, get_by_uuid): + fake_req = fake_build_request.fake_db_req() + get_by_uuid.return_value = fake_req + + req_obj = build_request.BuildRequest.get_by_instance_uuid(self.context, + fake_req['request_spec']['instance_uuid']) + + self.assertEqual(fake_req['request_spec']['instance_uuid'], + req_obj.request_spec.instance_uuid) + self.assertEqual(fake_req['project_id'], req_obj.project_id) + self.assertIsInstance(req_obj.request_spec, objects.RequestSpec) + get_by_uuid.assert_called_once_with(self.context, + fake_req['request_spec']['instance_uuid']) + + @mock.patch.object(build_request.BuildRequest, + '_create_in_db') + def test_create(self, create_in_db): + fake_req = fake_build_request.fake_db_req() + req_obj = fake_build_request.fake_req_obj(self.context, fake_req) + + def _test_create_args(self2, context, changes): + for field in [fields for fields in + build_request.BuildRequest.fields if fields not in + ['created_at', 'updated_at', 'request_spec', 'id']]: + self.assertEqual(fake_req[field], changes[field]) + self.assertEqual(fake_req['request_spec']['id'], + changes['request_spec_id']) + return fake_req + + with mock.patch.object(build_request.BuildRequest, '_create_in_db', + _test_create_args): + req_obj.create() + + def test_create_id_set(self): + req_obj = build_request.BuildRequest(self.context) + req_obj.id = 3 + + self.assertRaises(exception.ObjectActionError, req_obj.create) + + @mock.patch.object(build_request.BuildRequest, '_destroy_in_db') + def test_destroy(self, destroy_in_db): + req_obj = build_request.BuildRequest(self.context) + req_obj.id = 1 + req_obj.destroy() + + destroy_in_db.assert_called_once_with(self.context, req_obj.id) + + +class TestBuildRequestObject(test_objects._LocalTest, + _TestBuildRequestObject): + pass + + +class TestRemoteBuildRequestObject(test_objects._RemoteTest, + _TestBuildRequestObject): + pass diff --git a/nova/tests/unit/objects/test_objects.py b/nova/tests/unit/objects/test_objects.py index e9b27524a9..a8fac6fb6e 100644 --- a/nova/tests/unit/objects/test_objects.py +++ b/nova/tests/unit/objects/test_objects.py @@ -1105,6 +1105,7 @@ object_data = { 'BandwidthUsageList': '1.2-5fe7475ada6fe62413cbfcc06ec70746', 'BlockDeviceMapping': '1.16-12319f6f47f740a67a88a23f7c7ee6ef', 'BlockDeviceMappingList': '1.17-1e568eecb91d06d4112db9fd656de235', + 'BuildRequest': '1.0-e4ca475cabb07f73d8176f661afe8c55', 'CellMapping': '1.0-7f1a7e85a22bbb7559fc730ab658b9bd', 'ComputeNode': '1.16-2436e5b836fa0306a3c4e6d9e5ddacec', 'ComputeNodeList': '1.14-3b6f4f5ade621c40e70cb116db237844',