diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index a3461961a3..408631d2d0 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -103,6 +103,9 @@ class V1(controllers_base.APIBase): # Links to the federations resources federations = [link.Link] + nodegroups = [link.Link] + """Links to the nodegroups resource""" + @staticmethod def convert(): v1 = V1() @@ -171,6 +174,14 @@ class V1(controllers_base.APIBase): pecan.request.host_url, 'federations', '', bookmark=True)] + v1.nodegroups = [link.Link.make_link('self', pecan.request.host_url, + 'clusters/{cluster_id}', + 'nodegroups'), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'clusters/{cluster_id}', + 'nodegroups', + bookmark=True)] return v1 diff --git a/magnum/api/controllers/v1/cluster.py b/magnum/api/controllers/v1/cluster.py index 14d10c8a64..bf745525ec 100755 --- a/magnum/api/controllers/v1/cluster.py +++ b/magnum/api/controllers/v1/cluster.py @@ -27,6 +27,7 @@ from magnum.api.controllers import base from magnum.api.controllers import link from magnum.api.controllers.v1 import cluster_actions from magnum.api.controllers.v1 import collection +from magnum.api.controllers.v1 import nodegroup from magnum.api.controllers.v1 import types from magnum.api import expose from magnum.api import utils as api_utils @@ -334,6 +335,8 @@ class ClustersController(base.Controller): sort_key=sort_key, sort_dir=sort_dir) + nodegroups = nodegroup.NodeGroupController() + @expose.expose(ClusterCollection, types.uuid, int, wtypes.text, wtypes.text) def get_all(self, marker=None, limit=None, sort_key='id', diff --git a/magnum/api/controllers/v1/nodegroup.py b/magnum/api/controllers/v1/nodegroup.py new file mode 100644 index 0000000000..cf858ae130 --- /dev/null +++ b/magnum/api/controllers/v1/nodegroup.py @@ -0,0 +1,219 @@ +# Copyright (c) 2018 European Organization for Nuclear Research. +# 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 pecan +import wsme +from wsme import types as wtypes + +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 import expose +from magnum.api import utils as api_utils +from magnum.common import policy +from magnum import objects + + +class NodeGroup(base.APIBase): + """API representation of a Node group. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of NodeGroup. + """ + id = wsme.wsattr(wtypes.IntegerType(minimum=1)) + """unique id""" + + uuid = types.uuid + """Unique UUID for this nodegroup""" + + name = wsme.wsattr(wtypes.StringType(min_length=1, max_length=255), + default=None) + """Name of this nodegroup""" + + cluster_id = types.uuid + """Unique UUID for the cluster where the nodegroup belongs to""" + + project_id = wsme.wsattr(wtypes.text, readonly=True) + """Project UUID for this nodegroup""" + + docker_volume_size = wtypes.IntegerType(minimum=1) + """The size in GB of the docker volume""" + + labels = wtypes.DictType(str, str) + """One or more key/value pairs""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated nodegroup links""" + + flavor_id = wtypes.StringType(min_length=1, max_length=255) + """The flavor of this nodegroup""" + + image_id = wtypes.StringType(min_length=1, max_length=255) + """The image used for this nodegroup""" + + node_addresses = wsme.wsattr([wtypes.text], readonly=True) + """IP addresses of nodegroup nodes""" + + node_count = wsme.wsattr(wtypes.IntegerType(minimum=1), default=1) + """The node count for this nodegroup. Default to 1 if not set""" + + role = wtypes.StringType(min_length=1, max_length=255) + """The role of the nodes included in this nodegroup""" + + min_node_count = wsme.wsattr(wtypes.IntegerType(minimum=1), default=1) + """The minimum allowed nodes for this nodegroup. Default to 1 if not set""" + + max_node_count = wsme.wsattr(wtypes.IntegerType(minimum=1), default=None) + """The maximum allowed nodes for this nodegroup. Default to 1 if not set""" + + is_default = types.BooleanType() + """Specifies is a nodegroup was created by default or not""" + + def __init__(self, **kwargs): + super(NodeGroup, self).__init__() + self.fields = [] + for field in objects.NodeGroup.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)) + + @classmethod + def convert(cls, nodegroup, expand=True): + url = pecan.request.host_url + cluster_path = 'clusters/%s' % nodegroup.cluster_id + nodegroup_path = 'nodegroups/%s' % nodegroup.uuid + + ng = NodeGroup(**nodegroup.as_dict()) + if not expand: + ng.unset_fields_except(["uuid", "name", "flavor_id", "node_count", + "role", "is_default", "image_id"]) + else: + ng.links = [link.Link.make_link('self', url, cluster_path, + nodegroup_path), + link.Link.make_link('bookmark', url, + cluster_path, nodegroup_path, + bookmark=True)] + return ng + + +class NodeGroupCollection(collection.Collection): + """API representation of a collection of Node Groups.""" + + nodegroups = [NodeGroup] + """A list containing quota objects""" + + def __init__(self, **kwargs): + self._type = 'nodegroups' + + @staticmethod + def convert(nodegroups, limit, expand=True, **kwargs): + collection = NodeGroupCollection() + collection.nodegroups = [NodeGroup.convert(ng, expand) + for ng in nodegroups] + collection.next = collection.get_next(limit, + marker_attribute='id', + **kwargs) + return collection + + +class NodeGroupController(base.Controller): + """REST controller for Node Groups.""" + + def __init__(self): + super(NodeGroupController, self).__init__() + + def _get_nodegroup_collection(self, cluster_id, marker, limit, sort_key, + sort_dir, filters, expand=True): + + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.NodeGroup.list(pecan.request.context, + cluster_id, + marker) + + nodegroups = objects.NodeGroup.list(pecan.request.context, + cluster_id, + limit, + marker_obj, + sort_key=sort_key, + sort_dir=sort_dir, + filters=filters) + + return NodeGroupCollection.convert(nodegroups, + limit, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @expose.expose(NodeGroupCollection, types.uuid_or_name, int, int, + wtypes.text, wtypes.text, wtypes.text) + def get_all(self, cluster_id, marker=None, limit=None, sort_key='id', + sort_dir='asc', role=None): + """Retrieve a list of nodegroups. + + :param cluster_id: the cluster id or name + :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. + :param role: list all nodegroups with the specified role. + """ + context = pecan.request.context + policy.enforce(context, 'nodegroup:get_all', + action='nodegroup:get_all') + + if context.is_admin: + policy.enforce(context, 'nodegroup:get_all_all_projects', + action='nodegroup:get_all_all_projects') + context.all_tenants = True + + cluster = api_utils.get_resource('Cluster', cluster_id) + + filters = {} + if not context.is_admin: + filters = {"project_id": context.project_id} + if role: + filters.update({'role': role}) + + return self._get_nodegroup_collection(cluster.uuid, + marker, + limit, + sort_key, + sort_dir, + filters, + expand=False) + + @expose.expose(NodeGroup, types.uuid_or_name, types.uuid_or_name) + def get_one(self, cluster_id, nodegroup_id): + """Retrieve information for the given nodegroup in a cluster. + + :param id: cluster id. + :param resource: nodegroup id. + """ + context = pecan.request.context + policy.enforce(context, 'nodegroup:get', action='nodegroup:get') + if context.is_admin: + policy.enforce(context, "nodegroup:get_one_all_projects", + action="nodegroup:get_one_all_projects") + context.all_tenants = True + cluster = api_utils.get_resource('Cluster', cluster_id) + nodegroup = objects.NodeGroup.get(context, cluster.uuid, nodegroup_id) + return NodeGroup.convert(nodegroup) diff --git a/magnum/common/policies/__init__.py b/magnum/common/policies/__init__.py index 008bb91f84..b0733fa53e 100644 --- a/magnum/common/policies/__init__.py +++ b/magnum/common/policies/__init__.py @@ -22,6 +22,7 @@ from magnum.common.policies import cluster from magnum.common.policies import cluster_template from magnum.common.policies import federation from magnum.common.policies import magnum_service +from magnum.common.policies import nodegroup from magnum.common.policies import quota from magnum.common.policies import stats @@ -37,5 +38,6 @@ def list_rules(): federation.list_rules(), magnum_service.list_rules(), quota.list_rules(), - stats.list_rules() + stats.list_rules(), + nodegroup.list_rules() ) diff --git a/magnum/common/policies/nodegroup.py b/magnum/common/policies/nodegroup.py new file mode 100644 index 0000000000..5bac433fbd --- /dev/null +++ b/magnum/common/policies/nodegroup.py @@ -0,0 +1,73 @@ +# Copyright (c) 2018 European Organization for Nuclear Research. +# 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. + +from oslo_policy import policy + +from magnum.common.policies import base + + +NODEGROUP = 'nodegroup:%s' + + +rules = [ + policy.DocumentedRuleDefault( + name=NODEGROUP % 'get', + check_str=base.RULE_ADMIN_OR_OWNER, + description='Retrieve information about the given nodegroup.', + operations=[ + { + 'path': '/v1/clusters/{cluster_id}/nodegroup/{nodegroup}', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=NODEGROUP % 'get_all', + check_str=base.RULE_ADMIN_OR_OWNER, + description='Retrieve a list of nodegroups that belong to a cluster.', + operations=[ + { + 'path': '/v1/clusters/{cluster_id}/nodegroups/', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=NODEGROUP % 'get_all_all_projects', + check_str=base.RULE_ADMIN_API, + description='Retrieve a list of nodegroups across projects.', + operations=[ + { + 'path': '/v1/clusters/{cluster_id}/nodegroups/', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=NODEGROUP % 'get_one_all_projects', + check_str=base.RULE_ADMIN_API, + description='Retrieve infornation for a given nodegroup.', + operations=[ + { + 'path': '/v1/clusters/{cluster_id}/nodegroups/{nodegroup}', + 'method': 'GET' + } + ] + ), +] + + +def list_rules(): + return rules diff --git a/magnum/tests/unit/api/controllers/test_root.py b/magnum/tests/unit/api/controllers/test_root.py index b5285a8347..27eb6d44a4 100644 --- a/magnum/tests/unit/api/controllers/test_root.py +++ b/magnum/tests/unit/api/controllers/test_root.py @@ -90,7 +90,13 @@ class TestRootController(api_base.FunctionalTest): u'federations': [{u'href': u'http://localhost/v1/federations/', u'rel': u'self'}, {u'href': u'http://localhost/federations/', - u'rel': u'bookmark'}]} + u'rel': u'bookmark'}], + u'nodegroups': [{u'href': u'http://localhost/v1/clusters/' + '{cluster_id}/nodegroups', + u'rel': u'self'}, + {u'href': u'http://localhost/clusters/' + '{cluster_id}/nodegroups', + u'rel': u'bookmark'}]} def make_app(self, paste_file): file_name = self.get_path(paste_file) diff --git a/magnum/tests/unit/api/controllers/v1/test_nodegroup.py b/magnum/tests/unit/api/controllers/v1/test_nodegroup.py new file mode 100644 index 0000000000..62547f1b56 --- /dev/null +++ b/magnum/tests/unit/api/controllers/v1/test_nodegroup.py @@ -0,0 +1,179 @@ +# Copyright (c) 2018 European Organization for Nuclear Research. +# 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 mock + +from oslo_utils import uuidutils + +from magnum.api.controllers.v1 import nodegroup as api_nodegroup +import magnum.conf +from magnum import objects +from magnum.tests import base +from magnum.tests.unit.api import base as api_base +from magnum.tests.unit.api import utils as apiutils +from magnum.tests.unit.db import utils as db_utils +from magnum.tests.unit.objects import utils as obj_utils + +CONF = magnum.conf.CONF + + +class TestNodegroupObject(base.TestCase): + def test_nodegroup_init(self): + nodegroup_dict = apiutils.nodegroup_post_data() + del nodegroup_dict['node_count'] + del nodegroup_dict['min_node_count'] + del nodegroup_dict['max_node_count'] + nodegroup = api_nodegroup.NodeGroup(**nodegroup_dict) + self.assertEqual(1, nodegroup.node_count) + self.assertEqual(1, nodegroup.min_node_count) + self.assertIsNone(nodegroup.max_node_count) + + +class TestListNodegroups(api_base.FunctionalTest): + _expanded_attrs = ["id", "project_id", "docker_volume_size", "labels", + "node_addresses", "links"] + + _nodegroup_attrs = ["uuid", "name", "flavor_id", "node_count", "role", + "is_default", "image_id", "min_node_count", + "max_node_count"] + + def setUp(self): + super(TestListNodegroups, self).setUp() + obj_utils.create_test_cluster_template(self.context) + self.cluster_uuid = uuidutils.generate_uuid() + obj_utils.create_test_cluster( + self.context, uuid=self.cluster_uuid) + self.cluster = objects.Cluster.get_by_uuid(self.context, + self.cluster_uuid) + + def _test_list_nodegroups(self, cluster_id, filters=None, expected=None): + url = '/clusters/%s/nodegroups' % cluster_id + if filters is not None: + filter_list = ['%s=%s' % (k, v) for k, v in filters.items()] + url += '?' + '&'.join(f for f in filter_list) + response = self.get_json(url) + if expected is None: + expected = [] + ng_uuids = [ng['uuid'] for ng in response['nodegroups']] + self.assertEqual(expected, ng_uuids) + for ng in response['nodegroups']: + self._verify_attrs(self._nodegroup_attrs, ng) + self._verify_attrs(self._expanded_attrs, ng, positive=False) + + def test_get_all(self): + expected = [ng.uuid for ng in self.cluster.nodegroups] + self._test_list_nodegroups(self.cluster_uuid, expected=expected) + + def test_get_all_by_name(self): + expected = [ng.uuid for ng in self.cluster.nodegroups] + self._test_list_nodegroups(self.cluster.name, expected=expected) + + def test_get_all_by_name_non_default_ngs(self): + db_utils.create_test_nodegroup(cluster_id=self.cluster_uuid, + name='non_default_ng') + expected = [ng.uuid for ng in self.cluster.nodegroups] + self._test_list_nodegroups(self.cluster.name, expected=expected) + + def test_get_all_by_role(self): + filters = {'role': 'master'} + expected = [self.cluster.default_ng_master.uuid] + self._test_list_nodegroups(self.cluster.name, filters=filters, + expected=expected) + filters = {'role': 'worker'} + expected = [self.cluster.default_ng_worker.uuid] + self._test_list_nodegroups(self.cluster.name, filters=filters, + expected=expected) + + def test_get_all_by_non_existent_role(self): + filters = {'role': 'non-existent'} + self._test_list_nodegroups(self.cluster.name, filters=filters) + + @mock.patch("magnum.common.policy.enforce") + @mock.patch("magnum.common.context.make_context") + def test_get_all_as_admin(self, mock_context, mock_policy): + temp_uuid = uuidutils.generate_uuid() + obj_utils.create_test_cluster(self.context, uuid=temp_uuid, + project_id=temp_uuid) + self.context.is_admin = True + self.context.all_tenants = True + cluster = objects.Cluster.get_by_uuid(self.context, temp_uuid) + expected = [ng.uuid for ng in cluster.nodegroups] + self._test_list_nodegroups(cluster.uuid, expected=expected) + + def test_get_all_non_existent_cluster(self): + response = self.get_json('/clusters/not-here/nodegroups', + expect_errors=True) + self.assertEqual(404, response.status_code) + + def test_get_one(self): + worker = self.cluster.default_ng_worker + url = '/clusters/%s/nodegroups/%s' % (self.cluster.uuid, worker.uuid) + response = self.get_json(url) + self.assertEqual(worker.name, response['name']) + self._verify_attrs(self._nodegroup_attrs, response) + self._verify_attrs(self._expanded_attrs, response) + + def test_get_one_non_existent_ng(self): + url = '/clusters/%s/nodegroups/not-here' % self.cluster.uuid + response = self.get_json(url, expect_errors=True) + self.assertEqual(404, response.status_code) + + @mock.patch("magnum.common.policy.enforce") + @mock.patch("magnum.common.context.make_context") + def test_get_one_as_admin(self, mock_context, mock_policy): + temp_uuid = uuidutils.generate_uuid() + obj_utils.create_test_cluster(self.context, uuid=temp_uuid, + project_id=temp_uuid) + self.context.is_admin = True + self.context.all_tenants = True + cluster = objects.Cluster.get_by_uuid(self.context, temp_uuid) + worker = cluster.default_ng_worker + url = '/clusters/%s/nodegroups/%s' % (cluster.uuid, worker.uuid) + response = self.get_json(url) + self.assertEqual(worker.name, response['name']) + self._verify_attrs(self._nodegroup_attrs, response) + self._verify_attrs(self._expanded_attrs, response) + + +class TestNodeGroupPolicyEnforcement(api_base.FunctionalTest): + def setUp(self): + super(TestNodeGroupPolicyEnforcement, self).setUp() + obj_utils.create_test_cluster_template(self.context) + self.cluster_uuid = uuidutils.generate_uuid() + obj_utils.create_test_cluster( + self.context, uuid=self.cluster_uuid) + self.cluster = objects.Cluster.get_by_uuid(self.context, + self.cluster_uuid) + + def _common_policy_check(self, rule, func, *arg, **kwarg): + self.policy.set_rules({rule: "project:non_fake"}) + response = func(*arg, **kwarg) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_policy_disallow_get_all(self): + self._common_policy_check( + "nodegroup:get_all", self.get_json, + '/clusters/%s/nodegroups' % self.cluster_uuid, expect_errors=True) + + def test_policy_disallow_get_one(self): + worker = self.cluster.default_ng_worker + self._common_policy_check( + "nodegroup:get", self.get_json, + '/clusters/%s/nodegroups/%s' % (self.cluster.uuid, worker.uuid), + expect_errors=True) diff --git a/magnum/tests/unit/api/utils.py b/magnum/tests/unit/api/utils.py index 16e5522a59..866a7d90c4 100644 --- a/magnum/tests/unit/api/utils.py +++ b/magnum/tests/unit/api/utils.py @@ -95,3 +95,8 @@ def federation_post_data(**kw): federation = utils.get_test_federation(**kw) internal = federation_controller.FederationPatchType.internal_attrs() return remove_internal(federation, internal) + + +def nodegroup_post_data(**kw): + nodegroup = utils.get_test_nodegroup(**kw) + return nodegroup