diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index b5d7aa304d..0b935776de 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -28,6 +28,7 @@ import wsmeext.pecan as wsme_pecan from magnum.api.controllers import link from magnum.api.controllers.v1 import bay +from magnum.api.controllers.v1 import baymodel from magnum.api.controllers.v1 import container from magnum.api.controllers.v1 import node from magnum.api.controllers.v1 import pod @@ -89,6 +90,9 @@ class V1(APIBase): pods = [link.Link] """Links to the pods resource""" + baymodels = [link.Link] + """Links to the baymodels resource""" + bays = [link.Link] """Links to the bays resource""" @@ -119,6 +123,13 @@ class V1(APIBase): 'pods', '', bookmark=True) ] + v1.baymodels = [link.Link.make_link('self', pecan.request.host_url, + 'baymodels', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'bays', '', + bookmark=True) + ] v1.bays = [link.Link.make_link('self', pecan.request.host_url, 'bays', ''), link.Link.make_link('bookmark', @@ -147,6 +158,7 @@ class Controller(rest.RestController): """Version 1 API controller root.""" bays = bay.BaysController() + baymodels = baymodel.BayModelsController() containers = container.ContainersController() nodes = node.NodesController() pods = pod.PodsController() diff --git a/magnum/api/controllers/v1/baymodel.py b/magnum/api/controllers/v1/baymodel.py new file mode 100644 index 0000000000..ccc6ca6127 --- /dev/null +++ b/magnum/api/controllers/v1/baymodel.py @@ -0,0 +1,336 @@ +# All Rights Reserved. +# +# 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 + +import pecan +from pecan import rest +import wsme +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from magnum.api.controllers import base +from magnum.api.controllers import link +from magnum.api.controllers.v1 import collection +from magnum.api.controllers.v1 import types +from magnum.api.controllers.v1 import utils as api_utils +from magnum.common import exception +from magnum import objects + + +class BayModelPatchType(types.JsonPatchType): + + @staticmethod + def mandatory_attrs(): + return ['/baymodel_uuid'] + + +class BayModel(base.APIBase): + """API representation of a baymodel. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a baymodel. + """ + + _baymodel_uuid = None + + def _get_baymodel_uuid(self): + return self._baymodel_uuid + + def _set_baymodel_uuid(self, value): + if value and self._baymodel_uuid != value: + try: + # FIXME(comstud): One should only allow UUID here, but + # there seems to be a bug in that tests are passing an + # ID. See bug #1301046 for more details. + baymodel = objects.BayModel.get(pecan.request.context, value) + self._baymodel_uuid = baymodel.uuid + self.baymodel_id = baymodel.id + except exception.BayModelNotFound as e: + # Change error code because 404 (NotFound) is inappropriate + # response for a POST request to create a BayModel + e.code = 400 # BadRequest + raise e + elif value == wtypes.Unset: + self._baymodel_uuid = wtypes.Unset + + uuid = types.uuid + """Unique UUID for this baymodel""" + + name = wtypes.text + """The name of the bay model""" + + image_id = wtypes.text + """The image name or UUID to use as a base image for this baymodel""" + + flavor_id = wtypes.text + """The flavor of this bay model""" + + dns_nameserver = wtypes.text + """The DNS nameserver address""" + + keypair_id = wtypes.text + """The name or id of the nova ssh keypair""" + + external_network = wtypes.text + """The external network to attach the Bay""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated baymodel links""" + + def __init__(self, **kwargs): + self.fields = [] + fields = list(objects.BayModel.fields) + fields.append('baymodel_uuid') + for field in fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + # NOTE(lucasagomes): baymodel_id is an attribute created on-the-fly + # by _set_baymodel_uuid(), it needs to be present in the fields so + # that as_dict() will contain baymodel_id field when converting it + # before saving it in the database. + self.fields.append('baymodel_id') + setattr(self, 'baymodel_uuid', kwargs.get('baymodel_id', wtypes.Unset)) + + @staticmethod + def _convert_with_links(baymodel, url, expand=True): + if not expand: + baymodel.unset_fields_except(['uuid', 'name', 'type', 'image_id', + 'ironic_baymodel_id']) + + # never expose the baymodel_id attribute + baymodel.baymodel_id = wtypes.Unset + + baymodel.links = [link.Link.make_link('self', url, + 'baymodels', baymodel.uuid), + link.Link.make_link('bookmark', url, + 'baymodels', baymodel.uuid, + bookmark=True) + ] + return baymodel + + @classmethod + def convert_with_links(cls, rpc_baymodel, expand=True): + baymodel = BayModel(**rpc_baymodel.as_dict()) + return cls._convert_with_links(baymodel, pecan.request.host_url, + expand) + + @classmethod + def sample(cls, expand=True): + sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c', + name='example', + type='virt', + image_id='Fedora-k8s', + baymodel_count=1, + created_at=datetime.datetime.utcnow(), + updated_at=datetime.datetime.utcnow()) + # NOTE(lucasagomes): baymodel_uuid getter() method look at the + # _baymodel_uuid variable + sample._baymodel_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae' + return cls._convert_with_links(sample, 'http://localhost:9511', expand) + + +class BayModelCollection(collection.Collection): + """API representation of a collection of baymodels.""" + + baymodels = [BayModel] + """A list containing baymodels objects""" + + def __init__(self, **kwargs): + self._type = 'baymodels' + + @staticmethod + def convert_with_links(rpc_baymodels, limit, url=None, expand=False, + **kwargs): + collection = BayModelCollection() + collection.baymodels = [BayModel.convert_with_links(p, expand) + for p in rpc_baymodels] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.baymodels = [BayModel.sample(expand=False)] + return sample + + +class BayModelsController(rest.RestController): + """REST controller for BayModels.""" + + from_baymodels = False + """A flag to indicate if the requests to this controller are coming + from the top-level resource BayModels.""" + + _custom_actions = { + 'detail': ['GET'], + } + + def _get_baymodels_collection(self, marker, limit, + sort_key, sort_dir, expand=False, + resource_url=None): + + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.BayModel.get_by_uuid(pecan.request.context, + marker) + + baymodels = objects.BayModel.list(pecan.request.context, limit, + marker_obj, sort_key=sort_key, + sort_dir=sort_dir) + + return BayModelCollection.convert_with_links(baymodels, limit, + url=resource_url, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @wsme_pecan.wsexpose(BayModelCollection, types.uuid, + types.uuid, int, wtypes.text, wtypes.text) + def get_all(self, baymodel_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of baymodels. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + return self._get_baymodels_collection(marker, limit, sort_key, + sort_dir) + + @wsme_pecan.wsexpose(BayModelCollection, types.uuid, + types.uuid, int, wtypes.text, wtypes.text) + def detail(self, baymodel_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of baymodels with detail. + + :param baymodel_uuid: UUID of a baymodel, to get only baymodels for + that baymodel. + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + # NOTE(lucasagomes): /detail should only work agaist collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "baymodels": + raise exception.HTTPNotFound + + expand = True + resource_url = '/'.join(['baymodels', 'detail']) + return self._get_baymodels_collection(marker, limit, + sort_key, sort_dir, expand, + resource_url) + + @wsme_pecan.wsexpose(BayModel, types.uuid) + def get_one(self, baymodel_uuid): + """Retrieve information about the given baymodel. + + :param baymodel_uuid: UUID of a baymodel. + """ + if self.from_baymodels: + raise exception.OperationNotPermitted + + rpc_baymodel = objects.BayModel.get_by_uuid(pecan.request.context, + baymodel_uuid) + return BayModel.convert_with_links(rpc_baymodel) + + @wsme_pecan.wsexpose(BayModel, body=BayModel, status_code=201) + def post(self, baymodel): + """Create a new baymodel. + + :param baymodel: a baymodel within the request body. + """ + if self.from_baymodels: + raise exception.OperationNotPermitted + + new_baymodel = objects.BayModel(pecan.request.context, + **baymodel.as_dict()) + new_baymodel.create() + # Set the HTTP Location Header + pecan.response.location = link.build_url('baymodels', + new_baymodel.uuid) + return BayModel.convert_with_links(new_baymodel) + + @wsme.validate(types.uuid, [BayModelPatchType]) + @wsme_pecan.wsexpose(BayModel, types.uuid, body=[BayModelPatchType]) + def patch(self, baymodel_uuid, patch): + """Update an existing baymodel. + + :param baymodel_uuid: UUID of a baymodel. + :param patch: a json PATCH document to apply to this baymodel. + """ + if self.from_baymodels: + raise exception.OperationNotPermitted + + rpc_baymodel = objects.BayModel.get_by_uuid(pecan.request.context, + baymodel_uuid) + try: + baymodel_dict = rpc_baymodel.as_dict() + # NOTE(lucasagomes): + # 1) Remove baymodel_id because it's an internal value and + # not present in the API object + # 2) Add baymodel_uuid + baymodel_dict['baymodel_uuid'] = baymodel_dict.pop('baymodel_id', + None) + baymodel = BayModel(**api_utils.apply_jsonpatch(baymodel_dict, + patch)) + except api_utils.JSONPATCH_EXCEPTIONS as e: + raise exception.PatchError(patch=patch, reason=e) + + # Update only the fields that have changed + for field in objects.BayModel.fields: + try: + patch_val = getattr(baymodel, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if rpc_baymodel[field] != patch_val: + rpc_baymodel[field] = patch_val + + if hasattr(pecan.request, 'rpcapi'): + rpc_baymodel = objects.BayModel.get_by_id(pecan.request.context, + rpc_baymodel.baymodel_id) + topic = pecan.request.rpcapi.get_topic_for(rpc_baymodel) + + new_baymodel = pecan.request.rpcapi.update_baymodel( + pecan.request.context, rpc_baymodel, topic) + + return BayModel.convert_with_links(new_baymodel) + else: + rpc_baymodel.save() + return BayModel.convert_with_links(rpc_baymodel) + + @wsme_pecan.wsexpose(None, types.uuid, status_code=204) + def delete(self, baymodel_uuid): + """Delete a baymodel. + + :param baymodel_uuid: UUID of a baymodel. + """ + if self.from_baymodels: + raise exception.OperationNotPermitted + + rpc_baymodel = objects.BayModel.get_by_uuid(pecan.request.context, + baymodel_uuid) + rpc_baymodel.destroy() diff --git a/magnum/conductor/api.py b/magnum/conductor/api.py index a056c9465a..b2ed78936a 100644 --- a/magnum/conductor/api.py +++ b/magnum/conductor/api.py @@ -28,6 +28,21 @@ class API(rpc_service.API): super(API, self).__init__(transport, context, topic=cfg.CONF.conductor.topic) + # Bay Model Operations + + def baymodel_create(self, bay): + return self._call('baymodel_create', bay=bay) + + def baymodel_list(self, context, limit, marker, sort_key, sort_dir): + return objects.BayModel.list(context, limit, marker, sort_key, + sort_dir) + + def baymodel_delete(self, uuid): + return self._call('baymodel_delete', uuid=uuid) + + def baymodel_show(self, uuid): + return self._call('baymodel_show', uuid=uuid) + # Bay Operations def bay_create(self, bay): diff --git a/magnum/db/api.py b/magnum/db/api.py index f93620ee2c..9ea4f8d774 100644 --- a/magnum/db/api.py +++ b/magnum/db/api.py @@ -148,6 +148,113 @@ class Connection(object): :raises: BayNotFound """ + @abc.abstractmethod + def get_baymodel_list(self, columns=None, filters=None, limit=None, + marker=None, sort_key=None, sort_dir=None): + """Get specific columns for matching baymodels. + + Return a list of the specified columns for all baymodels that match the + specified filters. + + :param columns: List of column names to return. + Defaults to 'id' column when columns == None. + :param filters: Filters to apply. Defaults to None. + + :param limit: Maximum number of baymodels to return. + :param marker: the last item of the previous page; we return the next + result set. + :param sort_key: Attribute by which results should be sorted. + :param sort_dir: direction in which results should be sorted. + (asc, desc) + :returns: A list of tuples of the specified columns. + """ + + @abc.abstractmethod + def reserve_baymodel(self, tag, baymodel_id): + """Reserve a baymodel. + + To prevent other ManagerServices from manipulating the given + BayModel while a Task is performed, mark it reserved by this host. + + :param tag: A string uniquely identifying the reservation holder. + :param baymodel_id: A baymodel id or uuid. + :returns: A BayModel object. + :raises: BayModelNotFound if the baymodel is not found. + :raises: BayModelLocked if the baymodel is already reserved. + """ + + @abc.abstractmethod + def release_baymodel(self, tag, baymodel_id): + """Release the reservation on a baymodel. + + :param tag: A string uniquely identifying the reservation holder. + :param baymodel_id: A baymodel id or uuid. + :raises: BayModelNotFound if the baymodel is not found. + :raises: BayModelLocked if the baymodel is reserved by another host. + :raises: BayModelNotLocked if the baymodel was found to not have a + reservation at all. + """ + + @abc.abstractmethod + def create_baymodel(self, values): + """Create a new baymodel. + + :param values: A dict containing several items used to identify + and track the baymodel, and several dicts which are + passed into the Drivers when managing this baymodel. + For example: + + :: + + { + 'uuid': utils.generate_uuid(), + 'name': 'example', + 'type': 'virt' + } + :returns: A baymodel. + """ + + @abc.abstractmethod + def get_baymodel_by_id(self, baymodel_id): + """Return a baymodel. + + :param baymodel_id: The id of a baymodel. + :returns: A baymodel. + """ + + @abc.abstractmethod + def get_baymodel_by_uuid(self, baymodel_uuid): + """Return a baymodel. + + :param baymodel_uuid: The uuid of a baymodel. + :returns: A baymodel. + """ + + @abc.abstractmethod + def get_baymodel_by_instance(self, instance): + """Return a baymodel. + + :param instance: The instance name or uuid to search for. + :returns: A baymodel. + """ + + @abc.abstractmethod + def destroy_baymodel(self, baymodel_id): + """Destroy a baymodel and all associated interfaces. + + :param baymodel_id: The id or uuid of a baymodel. + """ + + @abc.abstractmethod + def update_baymodel(self, baymodel_id, values): + """Update properties of a baymodel. + + :param baymodel_id: The id or uuid of a baymodel. + :returns: A baymodel. + :raises: BayModelAssociated + :raises: BayModelNotFound + """ + @abc.abstractmethod def get_container_list(self, columns=None, filters=None, limit=None, marker=None, sort_key=None, sort_dir=None): diff --git a/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py b/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py index f410472499..21b025c9eb 100644 --- a/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py +++ b/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py @@ -43,6 +43,23 @@ def upgrade(): mysql_ENGINE='InnoDB', mysql_DEFAULT_CHARSET='UTF8' ) + op.create_table( + 'baymodel', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uuid', sa.String(length=36), nullable=True), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('type', sa.String(length=20), nullable=True), + sa.Column('image_id', sa.String(length=255), nullable=True), + sa.Column('flavor_id', sa.String(length=255), nullable=True), + sa.Column('keypair_id', sa.String(length=255), nullable=True), + sa.Column('external_network_id', sa.String(length=255), nullable=True), + sa.Column('dns_nameserver', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_ENGINE='InnoDB', + mysql_DEFAULT_CHARSET='UTF8' + ) op.create_table( 'container', sa.Column('created_at', sa.DateTime(), nullable=True), @@ -95,6 +112,7 @@ def upgrade(): def downgrade(): op.drop_table('bay') + op.drop_table('baymodel') op.drop_table('container') op.drop_table('node') op.drop_table('service') diff --git a/magnum/db/sqlalchemy/api.py b/magnum/db/sqlalchemy/api.py index 34f2345743..7ab23d74d9 100644 --- a/magnum/db/sqlalchemy/api.py +++ b/magnum/db/sqlalchemy/api.py @@ -292,6 +292,178 @@ class Connection(api.Connection): ref.update(values) return ref + def _add_baymodels_filters(self, query, filters): + if filters is None: + filters = [] + + if 'associated' in filters: + if filters['associated']: + query = query.filter(models.BayModel.instance_uuid is not None) + else: + query = query.filter(models.BayModel.instance_uuid is None) + if 'reserved' in filters: + if filters['reserved']: + query = query.filter(models.BayModel.reservation is not None) + else: + query = query.filter(models.BayModel.reservation is None) + if 'maintenance' in filters: + query = query.filter_by(maintenance=filters['maintenance']) + if 'driver' in filters: + query = query.filter_by(driver=filters['driver']) + if 'provision_state' in filters: + query = query.filter_by(provision_state=filters['provision_state']) + if 'provisioned_before' in filters: + limit = timeutils.utcnow() - datetime.timedelta( + seconds=filters['provisioned_before']) + query = query.filter(models.BayModel.provision_updated_at < limit) + + return query + + def get_baymodelinfo_list(self, columns=None, filters=None, limit=None, + marker=None, sort_key=None, sort_dir=None): + # list-ify columns default values because it is bad form + # to include a mutable list in function definitions. + if columns is None: + columns = [models.BayModel.id] + else: + columns = [getattr(models.BayModel, c) for c in columns] + + query = model_query(*columns, base_model=models.BayModel) + query = self._add_baymodels_filters(query, filters) + return _paginate_query(models.BayModel, limit, marker, + sort_key, sort_dir, query) + + def get_baymodel_list(self, filters=None, limit=None, marker=None, + sort_key=None, sort_dir=None): + query = model_query(models.BayModel) + query = self._add_baymodels_filters(query, filters) + return _paginate_query(models.BayModel, limit, marker, + sort_key, sort_dir, query) + + def reserve_baymodel(self, tag, baymodel_id): + session = get_session() + with session.begin(): + query = model_query(models.BayModel, session=session) + query = add_identity_filter(query, baymodel_id) + # be optimistic and assume we usually create a reservation + count = query.filter_by(reservation=None).update( + {'reservation': tag}, synchronize_session=False) + try: + baymodel = query.one() + if count != 1: + # Nothing updated and baymodel exists. Must already be + # locked. + raise exception.BayModelLocked(baymodel=baymodel_id, + host=baymodel['reservation']) + return baymodel + except NoResultFound: + raise exception.BayModelNotFound(baymodel_id) + + def release_baymodel(self, tag, baymodel_id): + session = get_session() + with session.begin(): + query = model_query(models.BayModel, session=session) + query = add_identity_filter(query, baymodel_id) + # be optimistic and assume we usually release a reservation + count = query.filter_by(reservation=tag).update( + {'reservation': None}, synchronize_session=False) + try: + if count != 1: + baymodel = query.one() + if baymodel['reservation'] is None: + raise exception.BayModelNotLocked(baymodel=baymodel_id) + else: + raise exception.BayModelLocked(baymodel=baymodel_id, + host=baymodel['reservation']) + except NoResultFound: + raise exception.BayModelNotFound(baymodel_id) + + def create_baymodel(self, values): + # ensure defaults are present for new baymodels + if not values.get('uuid'): + values['uuid'] = utils.generate_uuid() + + baymodel = models.BayModel() + baymodel.update(values) + try: + baymodel.save() + except db_exc.DBDuplicateEntry as exc: + if 'instance_uuid' in exc.columns: + raise exception.InstanceAssociated( + instance_uuid=values['instance_uuid'], + baymodel=values['uuid']) + raise exception.BayModelAlreadyExists(uuid=values['uuid']) + return baymodel + + def get_baymodel_by_id(self, baymodel_id): + query = model_query(models.BayModel).filter_by(id=baymodel_id) + try: + return query.one() + except NoResultFound: + raise exception.BayModelNotFound(baymodel=baymodel_id) + + def get_baymodel_by_uuid(self, baymodel_uuid): + query = model_query(models.BayModel).filter_by(uuid=baymodel_uuid) + try: + return query.one() + except NoResultFound: + raise exception.BayModelNotFound(baymodel=baymodel_uuid) + + def get_baymodel_by_instance(self, instance): + if not utils.is_uuid_like(instance): + raise exception.InvalidUUID(uuid=instance) + + query = (model_query(models.BayModel) + .filter_by(instance_uuid=instance)) + + try: + result = query.one() + except NoResultFound: + raise exception.InstanceNotFound(instance=instance) + + return result + + def destroy_baymodel(self, baymodel_id): + session = get_session() + with session.begin(): + query = model_query(models.BayModel, session=session) + query = add_identity_filter(query, baymodel_id) + query.delete() + + def update_baymodel(self, baymodel_id, values): + # NOTE(dtantsur): this can lead to very strange errors + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing BayModel.") + raise exception.InvalidParameterValue(err=msg) + + try: + return self._do_update_baymodel(baymodel_id, values) + except db_exc.DBDuplicateEntry: + raise exception.InstanceAssociated( + instance_uuid=values['instance_uuid'], + baymodel=baymodel_id) + + def _do_update_baymodel(self, baymodel_id, values): + session = get_session() + with session.begin(): + query = model_query(models.BayModel, session=session) + query = add_identity_filter(query, baymodel_id) + try: + ref = query.with_lockmode('update').one() + except NoResultFound: + raise exception.BayModelNotFound(baymodel=baymodel_id) + + # Prevent instance_uuid overwriting + if values.get("instance_uuid") and ref.instance_uuid: + raise exception.BayModelAssociated(baymodel=baymodel_id, + instance=ref.instance_uuid) + + if 'provision_state' in values: + values['provision_updated_at'] = timeutils.utcnow() + + ref.update(values) + return ref + def _add_containers_filters(self, query, filters): if filters is None: filters = [] diff --git a/magnum/db/sqlalchemy/models.py b/magnum/db/sqlalchemy/models.py index 6243209cd2..74ce46c3ce 100644 --- a/magnum/db/sqlalchemy/models.py +++ b/magnum/db/sqlalchemy/models.py @@ -127,6 +127,24 @@ class Bay(Base): node_count = Column(Integer()) +class BayModel(Base): + """Represents a bay model.""" + + __tablename__ = 'baymodel' + __table_args__ = ( + schema.UniqueConstraint('uuid', name='uniq_baymodel0uuid'), + table_args() + ) + id = Column(Integer, primary_key=True) + uuid = Column(String(36)) + name = Column(String(255)) + image_id = Column(String(255)) + flavor_id = Column(String(255)) + keypair_id = Column(String(255)) + external_network_id = Column(String(255)) + dns_nameserver = Column(String(255)) + + class Container(Base): """Represents a container.""" diff --git a/magnum/objects/__init__.py b/magnum/objects/__init__.py index 2781cee210..0bea9f7b23 100644 --- a/magnum/objects/__init__.py +++ b/magnum/objects/__init__.py @@ -13,6 +13,7 @@ # under the License. from magnum.objects import bay +from magnum.objects import baymodel from magnum.objects import container from magnum.objects import node from magnum.objects import pod @@ -21,11 +22,13 @@ from magnum.objects import service Container = container.Container Bay = bay.Bay +BayModel = baymodel.BayModel Node = node.Node Pod = pod.Pod Service = service.Service __all__ = (Bay, + BayModel, Container, Node, Pod, diff --git a/magnum/objects/baymodel.py b/magnum/objects/baymodel.py new file mode 100644 index 0000000000..aca74235d0 --- /dev/null +++ b/magnum/objects/baymodel.py @@ -0,0 +1,184 @@ +# 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 magnum.common import exception +from magnum.common import utils +from magnum.db import api as dbapi +from magnum.objects import base +from magnum.objects import utils as obj_utils + + +class BayModel(base.MagnumObject): + # Version 1.0: Initial version + # Version 1.1: Add get() and get_by_id() and get_by_address() and + # make get_by_uuid() only work with a uuid + # Version 1.2: Add create() and destroy() + # Version 1.3: Add list() + # Version 1.4: Add list_by_node_id() + VERSION = '1.0' + + dbapi = dbapi.get_instance() + + fields = { + 'id': int, + 'uuid': obj_utils.str_or_none, + 'name': obj_utils.str_or_none, + 'image_id': obj_utils.str_or_none, + 'flavor_id': obj_utils.str_or_none, + 'keypair_id': obj_utils.str_or_none, + 'dns_nameserver': obj_utils.str_or_none, + 'external_network_id': obj_utils.str_or_none + } + + @staticmethod + def _from_db_object(baymodel, db_baymodel): + """Converts a database entity to a formal object.""" + for field in baymodel.fields: + baymodel[field] = db_baymodel[field] + + baymodel.obj_reset_changes() + return baymodel + + @staticmethod + def _from_db_object_list(db_objects, cls, context): + """Converts a list of database entities to a list of formal objects.""" + return [BayModel._from_db_object(cls(context), obj) for obj in + db_objects] + + @base.remotable_classmethod + def get(cls, context, baymodel_id): + """Find a baymodel based on its id or uuid and return a BayModel object. + + :param baymodel_id: the id *or* uuid of a baymodel. + :returns: a :class:`BayModel` object. + """ + if utils.is_int_like(baymodel_id): + return cls.get_by_id(context, baymodel_id) + elif utils.is_uuid_like(baymodel_id): + return cls.get_by_uuid(context, baymodel_id) + else: + raise exception.InvalidIdentity(identity=baymodel_id) + + @base.remotable_classmethod + def get_by_id(cls, context, baymodel_id): + """Find a baymodel based on its integer id and return a BayModel object. + + :param baymodel_id: the id of a baymodel. + :returns: a :class:`BayModel` object. + """ + db_baymodel = cls.dbapi.get_baymodel_by_id(baymodel_id) + baymodel = BayModel._from_db_object(cls(context), db_baymodel) + return baymodel + + @base.remotable_classmethod + def get_by_uuid(cls, context, uuid): + """Find a baymodel based on uuid and return a :class:`BayModel` object. + + :param uuid: the uuid of a baymodel. + :param context: Security context + :returns: a :class:`BayModel` object. + """ + db_baymodel = cls.dbapi.get_baymodel_by_uuid(uuid) + baymodel = BayModel._from_db_object(cls(context), db_baymodel) + return baymodel + + @base.remotable_classmethod + def list(cls, context, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of BayModel objects. + + :param context: Security context. + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :returns: a list of :class:`BayModel` object. + + """ + db_baymodels = cls.dbapi.get_baymodel_list(limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir) + return BayModel._from_db_object_list(db_baymodels, cls, context) + + @base.remotable + def create(self, context=None): + """Create a BayModel record in the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: BayModel(context) + + """ + values = self.obj_get_changes() + db_baymodel = self.dbapi.create_baymodel(values) + self._from_db_object(self, db_baymodel) + + @base.remotable + def destroy(self, context=None): + """Delete the BayModel from the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: BayModel(context) + """ + self.dbapi.destroy_baymodel(self.uuid) + self.obj_reset_changes() + + @base.remotable + def save(self, context=None): + """Save updates to this BayModel. + + Updates will be made column by column based on the result + of self.what_changed(). + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: BayModel(context) + """ + updates = self.obj_get_changes() + self.dbapi.update_baymodel(self.uuid, updates) + + self.obj_reset_changes() + + @base.remotable + def refresh(self, context=None): + """Loads updates for this BayModel. + + Loads a baymodel with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded baymodel column by column, if there are any updates. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: BayModel(context) + """ + current = self.__class__.get_by_uuid(self._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/magnum/tests/api/controllers/v1/test_all_objects.py b/magnum/tests/api/controllers/v1/test_all_objects.py index c9dce963cb..8c01cbce65 100644 --- a/magnum/tests/api/controllers/v1/test_all_objects.py +++ b/magnum/tests/api/controllers/v1/test_all_objects.py @@ -34,34 +34,36 @@ class TestRootController(tests.FunctionalTest): self.assertEqual(expected, response.json) def test_v1_controller(self): - api_spec_url = (u'http://docs.openstack.org/developer' - u'/magnum/dev/api-spec-v1.html') expected = {u'media_types': - [{u'base': u'application/json', - u'type': u'application/vnd.openstack.magnum.v1+json'}], - u'links': [{u'href': u'http://localhost/v1/', - u'rel': u'self'}, - {u'href': api_spec_url, - u'type': u'text/html', - u'rel': u'describedby'}], - u'bays': [{u'href': u'http://localhost/v1/bays/', - u'rel': u'self'}, - {u'href': u'http://localhost/bays/', - u'rel': u'bookmark'}], - u'services': [{u'href': u'http://localhost/v1/services/', - u'rel': u'self'}, - {u'href': u'http://localhost/services/', - u'rel': u'bookmark'}], - u'pods': [{u'href': u'http://localhost/v1/pods/', - u'rel': u'self'}, - {u'href': u'http://localhost/pods/', - u'rel': u'bookmark'}], - u'id': u'v1', - u'containers': [{u'href': - u'http://localhost/v1/containers/', - u'rel': u'self'}, - {u'href': u'http://localhost/containers/', - u'rel': u'bookmark'}]} + [{u'base': u'application/json', + u'type': u'application/vnd.openstack.magnum.v1+json'}], + u'links': [{u'href': u'http://localhost/v1/', + u'rel': u'self'}, + {u'href': + u'http://docs.openstack.org/developer' + '/magnum/dev/api-spec-v1.html', + u'type': u'text/html', u'rel': u'describedby'}], + u'bays': [{u'href': u'http://localhost/v1/bays/', + u'rel': u'self'}, + {u'href': u'http://localhost/bays/', + u'rel': u'bookmark'}], + u'services': [{u'href': u'http://localhost/v1/services/', + u'rel': u'self'}, + {u'href': u'http://localhost/services/', + u'rel': u'bookmark'}], + u'baymodels': [{u'href': u'http://localhost/v1/baymodels/', + u'rel': u'self'}, + {u'href': u'http://localhost/bays/', + u'rel': u'bookmark'}], + u'pods': [{u'href': u'http://localhost/v1/pods/', + u'rel': u'self'}, + {u'href': u'http://localhost/pods/', + u'rel': u'bookmark'}], + u'id': u'v1', + u'containers': [{u'href': u'http://localhost/v1/containers/', + u'rel': u'self'}, + {u'href': u'http://localhost/containers/', + u'rel': u'bookmark'}]} response = self.app.get('/v1/') self.assertEqual(expected, response.json) @@ -120,6 +122,52 @@ class TestBayController(db_base.DbTestCase): self.assertEqual(0, len(c)) +class TestBayModelController(db_base.DbTestCase): + def simulate_rpc_baymodel_create(self, baymodel): + baymodel.create() + return baymodel + + def test_bay_model_api(self): + with patch.object(api.API, 'baymodel_create') as mock_method: + # Create a bay_model + mock_method.side_effect = self.simulate_rpc_baymodel_create + params = '{"name": "bay_model_example_A", "image_id": "nerdherd"}' + response = self.app.post('/v1/baymodels', + params=params, + content_type='application/json') + self.assertEqual(response.status_int, 201) + + # Get all baymodels + response = self.app.get('/v1/baymodels') + self.assertEqual(response.status_int, 200) + self.assertEqual(1, len(response.json)) + c = response.json['baymodels'][0] + self.assertIsNotNone(c.get('uuid')) + self.assertEqual('bay_model_example_A', c.get('name')) + self.assertEqual('nerdherd', c.get('image_id')) + + # Get just the one we created + response = self.app.get('/v1/baymodels/%s' % c.get('uuid')) + self.assertEqual(response.status_int, 200) + + # Update the description + params = [{'path': '/name', + 'value': 'bay_model_example_B', + 'op': 'replace'}] + response = self.app.patch_json('/v1/baymodels/%s' % c.get('uuid'), + params=params) + self.assertEqual(response.status_int, 200) + + # Delete the bay_model we created + response = self.app.delete('/v1/baymodels/%s' % c.get('uuid')) + self.assertEqual(response.status_int, 204) + + response = self.app.get('/v1/baymodels') + self.assertEqual(response.status_int, 200) + c = response.json['baymodels'] + self.assertEqual(0, len(c)) + + class TestNodeController(db_base.DbTestCase): def test_node_api(self): # Create a node