diff --git a/README.rst b/README.rst index 941c9183af..48558d7ac3 100644 --- a/README.rst +++ b/README.rst @@ -13,9 +13,10 @@ new Openstack project for containers. Architecture ------------ -There are four different types of objects in the Magnum system: +There are five different types of objects in the Magnum system: -* Bay: A physical machine or virtual machine where work is scheduled +* Bay: A collection of node objects where work is scheduled +* Node: A baremetal or virtual machine where work executes * Pod: A collection of containers running on one physical or virtual machine * Service: A port to Pod mapping * Container: A docker container @@ -40,7 +41,7 @@ one magnum-conductor process running. Features -------- -* Abstractions for bays, pods, services, and containers. +* Abstractions for bays, containers, nodes, pods, and services * Integration with Kubernetes and Docker for backend container technology. * Integration with Keystone for multi-tenant security. * Integraiton with Neutron for k8s multi-tenancy network security. diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index 8e357a1263..5a2153f128 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -29,6 +29,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 container +from magnum.api.controllers.v1 import node from magnum.api.controllers.v1 import pod @@ -146,6 +147,7 @@ class Controller(rest.RestController): bays = bay.BaysController() containers = container.ContainersController() + nodes = node.NodesController() pods = pod.PodsController() # services = service.ServicesController() diff --git a/magnum/api/controllers/v1/node.py b/magnum/api/controllers/v1/node.py new file mode 100644 index 0000000000..594623b817 --- /dev/null +++ b/magnum/api/controllers/v1/node.py @@ -0,0 +1,325 @@ +# Copyright 2013 UnitedStack Inc. +# 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 NodePatchType(types.JsonPatchType): + + @staticmethod + def mandatory_attrs(): + return ['/node_uuid'] + + +class Node(base.APIBase): + """API representation of a node. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a node. + """ + + _node_uuid = None + + def _get_node_uuid(self): + return self._node_uuid + + def _set_node_uuid(self, value): + if value and self._node_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. + node = objects.Node.get(pecan.request.context, value) + self._node_uuid = node.uuid + # NOTE(lucasagomes): Create the node_id attribute on-the-fly + # to satisfy the api -> rpc object + # conversion. + self.node_id = node.id + except exception.NodeNotFound as e: + # Change error code because 404 (NotFound) is inappropriate + # response for a POST request to create a Node + e.code = 400 # BadRequest + raise e + elif value == wtypes.Unset: + self._node_uuid = wtypes.Unset + + uuid = types.uuid + """Unique UUID for this node""" + + type = wtypes.text + """Type of this node""" + + image_id = wtypes.text + """The image name or UUID to use as a base image for this node""" + + ironic_node_id = wtypes.text + """The Ironic node ID associated with this node""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated node links""" + + def __init__(self, **kwargs): + self.fields = [] + fields = list(objects.Node.fields) + # NOTE(lucasagomes): node_uuid is not part of objects.Node.fields + # because it's an API-only attribute + fields.append('node_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): node_id is an attribute created on-the-fly + # by _set_node_uuid(), it needs to be present in the fields so + # that as_dict() will contain node_id field when converting it + # before saving it in the database. + self.fields.append('node_id') + setattr(self, 'node_uuid', kwargs.get('node_id', wtypes.Unset)) + + @staticmethod + def _convert_with_links(node, url, expand=True): + if not expand: + node.unset_fields_except(['uuid', 'name', 'type', 'image_id', + 'ironic_node_id']) + + # never expose the node_id attribute + node.node_id = wtypes.Unset + + node.links = [link.Link.make_link('self', url, + 'nodes', node.uuid), + link.Link.make_link('bookmark', url, + 'nodes', node.uuid, + bookmark=True) + ] + return node + + @classmethod + def convert_with_links(cls, rpc_node, expand=True): + node = Node(**rpc_node.as_dict()) + return cls._convert_with_links(node, 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', + node_count=1, + created_at=datetime.datetime.utcnow(), + updated_at=datetime.datetime.utcnow()) + # NOTE(lucasagomes): node_uuid getter() method look at the + # _node_uuid variable + sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae' + return cls._convert_with_links(sample, 'http://localhost:9511', expand) + + +class NodeCollection(collection.Collection): + """API representation of a collection of nodes.""" + + nodes = [Node] + """A list containing nodes objects""" + + def __init__(self, **kwargs): + self._type = 'nodes' + + @staticmethod + def convert_with_links(rpc_nodes, limit, url=None, expand=False, **kwargs): + collection = NodeCollection() + collection.nodes = [Node.convert_with_links(p, expand) + for p in rpc_nodes] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.nodes = [Node.sample(expand=False)] + return sample + + +class NodesController(rest.RestController): + """REST controller for Nodes.""" + + from_nodes = False + """A flag to indicate if the requests to this controller are coming + from the top-level resource Nodes.""" + + _custom_actions = { + 'detail': ['GET'], + } + + def _get_nodes_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.Node.get_by_uuid(pecan.request.context, + marker) + + nodes = objects.Node.list(pecan.request.context, limit, + marker_obj, sort_key=sort_key, + sort_dir=sort_dir) + + return NodeCollection.convert_with_links(nodes, limit, + url=resource_url, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @wsme_pecan.wsexpose(NodeCollection, types.uuid, + types.uuid, int, wtypes.text, wtypes.text) + def get_all(self, node_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of nodes. + + :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_nodes_collection(marker, limit, sort_key, + sort_dir) + + @wsme_pecan.wsexpose(NodeCollection, types.uuid, + types.uuid, int, wtypes.text, wtypes.text) + def detail(self, node_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of nodes with detail. + + :param node_uuid: UUID of a node, to get only nodes for that node. + :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 != "nodes": + raise exception.HTTPNotFound + + expand = True + resource_url = '/'.join(['nodes', 'detail']) + return self._get_nodes_collection(marker, limit, + sort_key, sort_dir, expand, + resource_url) + + @wsme_pecan.wsexpose(Node, types.uuid) + def get_one(self, node_uuid): + """Retrieve information about the given node. + + :param node_uuid: UUID of a node. + """ + if self.from_nodes: + raise exception.OperationNotPermitted + + rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid) + return Node.convert_with_links(rpc_node) + + @wsme_pecan.wsexpose(Node, body=Node, status_code=201) + def post(self, node): + """Create a new node. + + :param node: a node within the request body. + """ + if self.from_nodes: + raise exception.OperationNotPermitted + + new_node = objects.Node(pecan.request.context, + **node.as_dict()) + new_node.create() + # Set the HTTP Location Header + pecan.response.location = link.build_url('nodes', new_node.uuid) + return Node.convert_with_links(new_node) + + @wsme.validate(types.uuid, [NodePatchType]) + @wsme_pecan.wsexpose(Node, types.uuid, body=[NodePatchType]) + def patch(self, node_uuid, patch): + """Update an existing node. + + :param node_uuid: UUID of a node. + :param patch: a json PATCH document to apply to this node. + """ + if self.from_nodes: + raise exception.OperationNotPermitted + + rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid) + try: + node_dict = rpc_node.as_dict() + # NOTE(lucasagomes): + # 1) Remove node_id because it's an internal value and + # not present in the API object + # 2) Add node_uuid + node_dict['node_uuid'] = node_dict.pop('node_id', None) + node = Node(**api_utils.apply_jsonpatch(node_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.Node.fields: + try: + patch_val = getattr(node, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if rpc_node[field] != patch_val: + rpc_node[field] = patch_val + + if hasattr(pecan.request, 'rpcapi'): + rpc_node = objects.Node.get_by_id(pecan.request.context, + rpc_node.node_id) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + + new_node = pecan.request.rpcapi.update_node( + pecan.request.context, rpc_node, topic) + + return Node.convert_with_links(new_node) + else: + rpc_node.save() + return Node.convert_with_links(rpc_node) + + @wsme_pecan.wsexpose(None, types.uuid, status_code=204) + def delete(self, node_uuid): + """Delete a node. + + :param node_uuid: UUID of a node. + """ + if self.from_nodes: + raise exception.OperationNotPermitted + + rpc_node = objects.Node.get_by_uuid(pecan.request.context, + node_uuid) + rpc_node.destroy() diff --git a/magnum/db/api.py b/magnum/db/api.py index 0f1b21d772..f93620ee2c 100644 --- a/magnum/db/api.py +++ b/magnum/db/api.py @@ -257,6 +257,111 @@ class Connection(object): """ @abc.abstractmethod + def get_node_list(self, columns=None, filters=None, limit=None, + marker=None, sort_key=None, sort_dir=None): + """Get specific columns for matching nodes. + + Return a list of the specified columns for all nodes 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 nodes 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_node(self, tag, node_id): + """Reserve a node. + + To prevent other ManagerServices from manipulating the given + Node while a Task is performed, mark it reserved by this host. + + :param tag: A string uniquely identifying the reservation holder. + :param node_id: A node id or uuid. + :returns: A Node object. + :raises: NodeNotFound if the node is not found. + :raises: NodeLocked if the node is already reserved. + """ + + @abc.abstractmethod + def release_node(self, tag, node_id): + """Release the reservation on a node. + + :param tag: A string uniquely identifying the reservation holder. + :param node_id: A node id or uuid. + :raises: NodeNotFound if the node is not found. + :raises: NodeLocked if the node is reserved by another host. + :raises: NodeNotLocked if the node was found to not have a + reservation at all. + """ + + @abc.abstractmethod + def create_node(self, values): + """Create a new node. + + :param values: A dict containing several items used to identify + and track the node, and several dicts which are passed + into the Drivers when managing this node. For example: + + :: + + { + 'uuid': utils.generate_uuid(), + 'name': 'example', + 'type': 'virt' + } + :returns: A node. + """ + + @abc.abstractmethod + def get_node_by_id(self, node_id): + """Return a node. + + :param node_id: The id of a node. + :returns: A node. + """ + + @abc.abstractmethod + def get_node_by_uuid(self, node_uuid): + """Return a node. + + :param node_uuid: The uuid of a node. + :returns: A node. + """ + + @abc.abstractmethod + def get_node_by_instance(self, instance): + """Return a node. + + :param instance: The instance name or uuid to search for. + :returns: A node. + """ + + @abc.abstractmethod + def destroy_node(self, node_id): + """Destroy a node and all associated interfaces. + + :param node_id: The id or uuid of a node. + """ + + @abc.abstractmethod + def update_node(self, node_id, values): + """Update properties of a node. + + :param node_id: The id or uuid of a node. + :returns: A node. + :raises: NodeAssociated + :raises: NodeNotFound + """ + @abc.abstractmethod def get_pod_list(self, columns=None, filters=None, limit=None, marker=None, sort_key=None, sort_dir=None): """Get specific columns for matching pods. diff --git a/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py b/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py index ba833967cb..68a625addf 100644 --- a/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py +++ b/magnum/db/sqlalchemy/alembic/versions/2581ebaf0cb2_initial_migration.py @@ -52,6 +52,19 @@ def upgrade(): mysql_ENGINE='InnoDB', mysql_DEFAULT_CHARSET='UTF8' ) + op.create_table( + 'node', + 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('type', sa.String(length=20), nullable=True), + sa.Column('image_id', sa.String(length=255), nullable=True), + sa.Column('ironic_node_id', sa.String(length=36), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_ENGINE='InnoDB', + mysql_DEFAULT_CHARSET='UTF8' + ) op.create_table( 'pod', sa.Column('created_at', sa.DateTime(), nullable=True), @@ -78,6 +91,7 @@ def upgrade(): def downgrade(): op.drop_table('bay') op.drop_table('container') + op.drop_table('node') op.drop_table('service') op.drop_table('pod') # We should probably remove the drops later ;-) diff --git a/magnum/db/sqlalchemy/api.py b/magnum/db/sqlalchemy/api.py index d300e86621..7b1b772f75 100644 --- a/magnum/db/sqlalchemy/api.py +++ b/magnum/db/sqlalchemy/api.py @@ -466,6 +466,178 @@ class Connection(api.Connection): ref.update(values) return ref + def _add_nodes_filters(self, query, filters): + if filters is None: + filters = [] + + if 'associated' in filters: + if filters['associated']: + query = query.filter(models.Node.instance_uuid is not None) + else: + query = query.filter(models.Node.instance_uuid is None) + if 'reserved' in filters: + if filters['reserved']: + query = query.filter(models.Node.reservation is not None) + else: + query = query.filter(models.Node.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.Node.provision_updated_at < limit) + + return query + + def get_nodeinfo_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.Node.id] + else: + columns = [getattr(models.Node, c) for c in columns] + + query = model_query(*columns, base_model=models.Node) + query = self._add_nodes_filters(query, filters) + return _paginate_query(models.Node, limit, marker, + sort_key, sort_dir, query) + + def get_node_list(self, filters=None, limit=None, marker=None, + sort_key=None, sort_dir=None): + query = model_query(models.Node) + query = self._add_nodes_filters(query, filters) + return _paginate_query(models.Node, limit, marker, + sort_key, sort_dir, query) + + def reserve_node(self, tag, node_id): + session = get_session() + with session.begin(): + query = model_query(models.Node, session=session) + query = add_identity_filter(query, node_id) + # be optimistic and assume we usually create a reservation + count = query.filter_by(reservation=None).update( + {'reservation': tag}, synchronize_session=False) + try: + node = query.one() + if count != 1: + # Nothing updated and node exists. Must already be + # locked. + raise exception.NodeLocked(node=node_id, + host=node['reservation']) + return node + except NoResultFound: + raise exception.NodeNotFound(node_id) + + def release_node(self, tag, node_id): + session = get_session() + with session.begin(): + query = model_query(models.Node, session=session) + query = add_identity_filter(query, node_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: + node = query.one() + if node['reservation'] is None: + raise exception.NodeNotLocked(node=node_id) + else: + raise exception.NodeLocked(node=node_id, + host=node['reservation']) + except NoResultFound: + raise exception.NodeNotFound(node_id) + + def create_node(self, values): + # ensure defaults are present for new nodes + if not values.get('uuid'): + values['uuid'] = utils.generate_uuid() + + node = models.Node() + node.update(values) + try: + node.save() + except db_exc.DBDuplicateEntry as exc: + if 'instance_uuid' in exc.columns: + raise exception.InstanceAssociated( + instance_uuid=values['instance_uuid'], + node=values['uuid']) + raise exception.NodeAlreadyExists(uuid=values['uuid']) + return node + + def get_node_by_id(self, node_id): + query = model_query(models.Node).filter_by(id=node_id) + try: + return query.one() + except NoResultFound: + raise exception.NodeNotFound(node=node_id) + + def get_node_by_uuid(self, node_uuid): + query = model_query(models.Node).filter_by(uuid=node_uuid) + try: + return query.one() + except NoResultFound: + raise exception.NodeNotFound(node=node_uuid) + + def get_node_by_instance(self, instance): + if not utils.is_uuid_like(instance): + raise exception.InvalidUUID(uuid=instance) + + query = (model_query(models.Node) + .filter_by(instance_uuid=instance)) + + try: + result = query.one() + except NoResultFound: + raise exception.InstanceNotFound(instance=instance) + + return result + + def destroy_node(self, node_id): + session = get_session() + with session.begin(): + query = model_query(models.Node, session=session) + query = add_identity_filter(query, node_id) + query.delete() + + def update_node(self, node_id, values): + # NOTE(dtantsur): this can lead to very strange errors + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing Node.") + raise exception.InvalidParameterValue(err=msg) + + try: + return self._do_update_node(node_id, values) + except db_exc.DBDuplicateEntry: + raise exception.InstanceAssociated( + instance_uuid=values['instance_uuid'], + node=node_id) + + def _do_update_node(self, node_id, values): + session = get_session() + with session.begin(): + query = model_query(models.Node, session=session) + query = add_identity_filter(query, node_id) + try: + ref = query.with_lockmode('update').one() + except NoResultFound: + raise exception.NodeNotFound(node=node_id) + + # Prevent instance_uuid overwriting + if values.get("instance_uuid") and ref.instance_uuid: + raise exception.NodeAssociated(node=node_id, + instance=ref.instance_uuid) + + if 'provision_state' in values: + values['provision_updated_at'] = timeutils.utcnow() + + ref.update(values) + return ref + def _add_pods_filters(self, query, filters): if filters is None: filters = [] diff --git a/magnum/db/sqlalchemy/models.py b/magnum/db/sqlalchemy/models.py index 464f568d37..b48dbe7379 100644 --- a/magnum/db/sqlalchemy/models.py +++ b/magnum/db/sqlalchemy/models.py @@ -140,6 +140,21 @@ class Container(Base): name = Column(String(255)) +class Node(Base): + """Represents a node.""" + + __tablename__ = 'node' + __table_args__ = ( + schema.UniqueConstraint('uuid', name='uniq_node0uuid'), + table_args() + ) + id = Column(Integer, primary_key=True) + uuid = Column(String(36)) + type = Column(String(20)) + image_id = Column(String(255)) + ironic_node_id = Column(String(36)) + + class Pod(Base): """Represents a pod.""" diff --git a/magnum/objects/__init__.py b/magnum/objects/__init__.py index a6a558cd4e..2781cee210 100644 --- a/magnum/objects/__init__.py +++ b/magnum/objects/__init__.py @@ -14,16 +14,19 @@ from magnum.objects import bay from magnum.objects import container +from magnum.objects import node from magnum.objects import pod from magnum.objects import service Container = container.Container Bay = bay.Bay +Node = node.Node Pod = pod.Pod Service = service.Service __all__ = (Bay, Container, + Node, Pod, Service) diff --git a/magnum/objects/node.py b/magnum/objects/node.py new file mode 100644 index 0000000000..3ab2088cd2 --- /dev/null +++ b/magnum/objects/node.py @@ -0,0 +1,180 @@ +# 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 Node(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, + 'type': obj_utils.str_or_none, + 'image_id': obj_utils.str_or_none, + 'ironic_node_id': obj_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 + + @staticmethod + def _from_db_object_list(db_objects, cls, context): + """Converts a list of database entities to a list of formal objects.""" + return [Node._from_db_object(cls(context), obj) for obj in db_objects] + + @base.remotable_classmethod + def get(cls, context, node_id): + """Find a node based on its id or uuid and return a Node object. + + :param node_id: the id *or* uuid of a node. + :returns: a :class:`Node` object. + """ + if utils.is_int_like(node_id): + return cls.get_by_id(context, node_id) + elif utils.is_uuid_like(node_id): + return cls.get_by_uuid(context, node_id) + else: + raise exception.InvalidIdentity(identity=node_id) + + @base.remotable_classmethod + def get_by_id(cls, context, node_id): + """Find a node based on its integer id and return a Node object. + + :param node_id: the id of a node. + :returns: a :class:`Node` object. + """ + db_node = cls.dbapi.get_node_by_id(node_id) + node = Node._from_db_object(cls(context), db_node) + return node + + @base.remotable_classmethod + def get_by_uuid(cls, context, uuid): + """Find a node based on uuid and return a :class:`Node` object. + + :param uuid: the uuid of a node. + :param context: Security context + :returns: a :class:`Node` object. + """ + db_node = cls.dbapi.get_node_by_uuid(uuid) + node = Node._from_db_object(cls(context), db_node) + return node + + @base.remotable_classmethod + def list(cls, context, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of Node 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:`Node` object. + + """ + db_nodes = cls.dbapi.get_node_list(limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir) + return Node._from_db_object_list(db_nodes, cls, context) + + @base.remotable + def create(self, context=None): + """Create a Node 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.: Node(context) + + """ + values = self.obj_get_changes() + db_node = self.dbapi.create_node(values) + self._from_db_object(self, db_node) + + @base.remotable + def destroy(self, context=None): + """Delete the Node 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.: Node(context) + """ + self.dbapi.destroy_node(self.uuid) + self.obj_reset_changes() + + @base.remotable + def save(self, context=None): + """Save updates to this Node. + + 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.: Node(context) + """ + updates = self.obj_get_changes() + self.dbapi.update_node(self.uuid, updates) + + self.obj_reset_changes() + + @base.remotable + def refresh(self, context=None): + """Loads updates for this Node. + + Loads a node with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded node 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.: Node(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/test_functional.py b/magnum/tests/test_functional.py index 2ff906deb9..5f6a415b17 100644 --- a/magnum/tests/test_functional.py +++ b/magnum/tests/test_functional.py @@ -111,6 +111,38 @@ class TestBayController(db_base.DbTestCase): self.assertEqual(0, len(c)) +class TestNodeController(db_base.DbTestCase): + def test_node_api(self): + # Create a node + params = '{"type": "bare", "image_id": "Fedora"}' + response = self.app.post('/v1/nodes', + params=params, + content_type='application/json') + self.assertEqual(response.status_int, 201) + + # Get all nodes + response = self.app.get('/v1/nodes') + self.assertEqual(response.status_int, 200) + self.assertEqual(1, len(response.json)) + c = response.json['nodes'][0] + self.assertIsNotNone(c.get('uuid')) + self.assertEqual('bare', c.get('type')) + self.assertEqual('Fedora', c.get('image_id')) + + # Get just the one we created + response = self.app.get('/v1/nodes/%s' % c.get('uuid')) + self.assertEqual(response.status_int, 200) + + # Delete the node we created + response = self.app.delete('/v1/nodes/%s' % c.get('uuid')) + self.assertEqual(response.status_int, 204) + + response = self.app.get('/v1/nodes') + self.assertEqual(response.status_int, 200) + c = response.json['nodes'] + self.assertEqual(0, len(c)) + + class TestPodController(db_base.DbTestCase): def test_pod_api(self): # Create a pod