Magnum stats API

This change introduces a new /stats REST endpoint that
provide the following basic information;
1) Total number of clusters and nodes for the given tenant.
2) Total number of clusters and nodes across all the tenants.
Follow-up patches include more stats.

Change-Id: Iac0bf9343549de31654545d5b1fd7601e56142a7
Partially Implements blueprint magnum-stats-api
This commit is contained in:
Vijendar Komalla 2016-10-27 11:16:18 -05:00
parent 7f5e10a38f
commit 51e833137b
17 changed files with 353 additions and 8 deletions

View File

@ -3,7 +3,7 @@
{ {
"status":"CURRENT", "status":"CURRENT",
"min_version":"1.1", "min_version":"1.1",
"max_version":"1.3", "max_version":"1.4",
"id":"v1", "id":"v1",
"links":[ "links":[
{ {

View File

@ -38,5 +38,6 @@
"certificate:create": "rule:admin_or_user", "certificate:create": "rule:admin_or_user",
"certificate:get": "rule:admin_or_user", "certificate:get": "rule:admin_or_user",
"magnum-service:get_all": "rule:admin_api" "magnum-service:get_all": "rule:admin_api",
"stats:get_all": "rule:admin_or_owner"
} }

View File

@ -30,6 +30,7 @@ from magnum.api.controllers.v1 import certificate
from magnum.api.controllers.v1 import cluster from magnum.api.controllers.v1 import cluster
from magnum.api.controllers.v1 import cluster_template from magnum.api.controllers.v1 import cluster_template
from magnum.api.controllers.v1 import magnum_services from magnum.api.controllers.v1 import magnum_services
from magnum.api.controllers.v1 import stats
from magnum.api.controllers import versions as ver from magnum.api.controllers import versions as ver
from magnum.api import expose from magnum.api import expose
from magnum.api import http_error from magnum.api import http_error
@ -91,6 +92,9 @@ class V1(controllers_base.APIBase):
mservices = [link.Link] mservices = [link.Link]
"""Links to the magnum-services resource""" """Links to the magnum-services resource"""
stats = [link.Link]
"""Links to the stats resource"""
@staticmethod @staticmethod
def convert(): def convert():
v1 = V1() v1 = V1()
@ -141,6 +145,12 @@ class V1(controllers_base.APIBase):
pecan.request.host_url, pecan.request.host_url,
'mservices', '', 'mservices', '',
bookmark=True)] bookmark=True)]
v1.stats = [link.Link.make_link('self', pecan.request.host_url,
'stats', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'stats', '',
bookmark=True)]
return v1 return v1
@ -153,6 +163,7 @@ class Controller(controllers_base.Controller):
clustertemplates = cluster_template.ClusterTemplatesController() clustertemplates = cluster_template.ClusterTemplatesController()
certificates = certificate.CertificateController() certificates = certificate.CertificateController()
mservices = magnum_services.MagnumServiceController() mservices = magnum_services.MagnumServiceController()
stats = stats.StatsController()
@expose.expose(V1) @expose.expose(V1)
def get(self): def get(self):

View File

@ -0,0 +1,73 @@
# 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
from wsme import types as wtypes
from magnum.api.controllers import base
from magnum.api import expose
from magnum.common import exception
from magnum.common import policy
from magnum.i18n import _
from magnum import objects
class Stats(base.APIBase):
clusters = wtypes.IntegerType(minimum=0)
nodes = wtypes.IntegerType(minimum=0)
def __init__(self, **kwargs):
self.fields = []
for field in objects.Stats.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, rpc_stats):
return Stats(**rpc_stats.as_dict())
class StatsController(base.Controller):
"""REST controller for Stats."""
def __init__(self, **kwargs):
super(StatsController, self).__init__()
@base.Controller.api_version("1.4") # noqa
@expose.expose(Stats, wtypes.text, wtypes.text)
def get_all(self, project_id=None, type="cluster"):
"""Retrieve magnum stats.
"""
context = pecan.request.context
policy.enforce(context, 'stats:get_all', action='stats:get_all')
allowed_stats = ["cluster"]
if type.lower() not in allowed_stats:
msg = _("Invalid stats type. Allowed values are '%s'")
allowed_str = ','.join(allowed_stats)
raise exception.InvalidParameterValue(err=msg % allowed_str)
# 1.If the requester is not an admin and trying to request stats for
# different tenant, then reject the request
# 2.If the requester is not an admin and project_id was not provided,
# then return self stats
if not context.is_admin:
project_id = project_id if project_id else context.project_id
if project_id != context.project_id:
raise exception.NotAuthorized()
stats = objects.Stats.get_cluster_stats(context, project_id)
return Stats.convert(stats)

View File

@ -36,10 +36,11 @@ REST_API_VERSION_HISTORY = """REST API Version History:
* 1.1 - Initial version * 1.1 - Initial version
* 1.2 - Async bay operations support * 1.2 - Async bay operations support
* 1.3 - Add bay rollback support * 1.3 - Add bay rollback support
* 1.4 - Add stats API
""" """
BASE_VER = '1.1' BASE_VER = '1.1'
CURRENT_MAX_VER = '1.3' CURRENT_MAX_VER = '1.4'
class Version(object): class Version(object):

View File

@ -44,3 +44,16 @@ user documentation.
For example:- For example:-
- http://XXX/v1/clusters/XXX/?rollback=True or - http://XXX/v1/clusters/XXX/?rollback=True or
- http://XXX/v1/bays/XXX/?rollback=True - http://XXX/v1/bays/XXX/?rollback=True
1.4
---
Add stats API
An admin user can get total number of clusters and nodes for a specified
tenant or for all the tenants and also a non-admin user can get self stats.
For example:-
- http://XXX/v1/stats or
- http://XXX/v1/stats?project_id=<project-id> or
- http://XXX/v1/stats?project_id=<project-id>&type=<stats-type>

View File

@ -106,6 +106,15 @@ class Connection(object):
:returns: A cluster. :returns: A cluster.
""" """
@abc.abstractmethod
def get_cluster_stats(self, context, project_id):
"""Return clusters stats for the given project.
:param context: The security context
:param project_id: The project id.
:returns: clusters, nodes count.
"""
@abc.abstractmethod @abc.abstractmethod
def destroy_cluster(self, cluster_id): def destroy_cluster(self, cluster_id):
"""Destroy a cluster and all associated interfaces. """Destroy a cluster and all associated interfaces.

View File

@ -22,6 +22,7 @@ from oslo_utils import timeutils
from oslo_utils import uuidutils from oslo_utils import uuidutils
from sqlalchemy.orm.exc import MultipleResultsFound from sqlalchemy.orm.exc import MultipleResultsFound
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql import func
from magnum.common import exception from magnum.common import exception
import magnum.conf import magnum.conf
@ -190,6 +191,23 @@ class Connection(api.Connection):
except NoResultFound: except NoResultFound:
raise exception.ClusterNotFound(cluster=cluster_uuid) raise exception.ClusterNotFound(cluster=cluster_uuid)
def get_cluster_stats(self, context, project_id=None):
query = model_query(models.Cluster)
node_count_col = models.Cluster.node_count
master_count_col = models.Cluster.master_count
ncfunc = func.sum(node_count_col + master_count_col)
if project_id:
query = query.filter_by(project_id=project_id)
nquery = query.session.query(ncfunc.label("nodes")).filter_by(
project_id=project_id)
else:
nquery = query.session.query(ncfunc.label("nodes"))
clusters = query.count()
nodes = int(nquery.one()[0]) if nquery.one()[0] else 0
return clusters, nodes
def destroy_cluster(self, cluster_id): def destroy_cluster(self, cluster_id):
session = get_session() session = get_session()
with session.begin(): with session.begin():

View File

@ -16,6 +16,7 @@ from magnum.objects import certificate
from magnum.objects import cluster from magnum.objects import cluster
from magnum.objects import cluster_template from magnum.objects import cluster_template
from magnum.objects import magnum_service from magnum.objects import magnum_service
from magnum.objects import stats
from magnum.objects import x509keypair from magnum.objects import x509keypair
@ -24,8 +25,10 @@ ClusterTemplate = cluster_template.ClusterTemplate
MagnumService = magnum_service.MagnumService MagnumService = magnum_service.MagnumService
X509KeyPair = x509keypair.X509KeyPair X509KeyPair = x509keypair.X509KeyPair
Certificate = certificate.Certificate Certificate = certificate.Certificate
Stats = stats.Stats
__all__ = (Cluster, __all__ = (Cluster,
ClusterTemplate, ClusterTemplate,
MagnumService, MagnumService,
X509KeyPair, X509KeyPair,
Certificate) Certificate,
Stats)

View File

@ -40,8 +40,9 @@ class Cluster(base.MagnumPersistentObject, base.MagnumObject,
# Rename 'bay_create_timeout' to 'create_timeout' # Rename 'bay_create_timeout' to 'create_timeout'
# Version 1.10: Added 'keypair' field # Version 1.10: Added 'keypair' field
# Version 1.11: Added 'RESUME_FAILED' in status field # Version 1.11: Added 'RESUME_FAILED' in status field
# Version 1.12: Added 'get_stats' method
VERSION = '1.11' VERSION = '1.12'
dbapi = dbapi.get_instance() dbapi = dbapi.get_instance()
@ -172,6 +173,15 @@ class Cluster(base.MagnumPersistentObject, base.MagnumObject,
filters=filters) filters=filters)
return Cluster._from_db_object_list(db_clusters, cls, context) return Cluster._from_db_object_list(db_clusters, cls, context)
@base.remotable_classmethod
def get_stats(cls, context, project_id=None):
"""Return a list of Cluster objects.
:param context: Security context.
:param project_id: project id
"""
return cls.dbapi.get_cluster_stats(project_id)
@base.remotable @base.remotable
def create(self, context=None): def create(self, context=None):
"""Create a Cluster record in the DB. """Create a Cluster record in the DB.

44
magnum/objects/stats.py Normal file
View File

@ -0,0 +1,44 @@
# 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 oslo_versionedobjects import fields
from magnum.db import api as dbapi
from magnum.objects import base
@base.MagnumObjectRegistry.register
class Stats(base.MagnumObject, base.MagnumObjectDictCompat):
# Version 1.0: Initial version
VERSION = '1.0'
dbapi = dbapi.get_instance()
fields = {
'clusters': fields.IntegerField(),
'nodes': fields.IntegerField(nullable=True)
}
@base.remotable_classmethod
def get_cluster_stats(cls, context, project_id=None):
"""Return cluster stats for the given project.
:param context: The security context
:param project_id: project id
"""
clusters, nodes = cls.dbapi.get_cluster_stats(context, project_id)
return cls(clusters=clusters, nodes=nodes)

View File

@ -52,6 +52,7 @@ policy_data = """
"certificate:create": "", "certificate:create": "",
"certificate:get": "", "certificate:get": "",
"magnum-service:get_all": "" "magnum-service:get_all": "",
"stats:get_all": ""
} }
""" """

View File

@ -40,7 +40,7 @@ class TestRootController(api_base.FunctionalTest):
[{u'href': u'http://localhost/v1/', [{u'href': u'http://localhost/v1/',
u'rel': u'self'}], u'rel': u'self'}],
u'status': u'CURRENT', u'status': u'CURRENT',
u'max_version': u'1.3', u'max_version': u'1.4',
u'min_version': u'1.1'}]} u'min_version': u'1.1'}]}
self.v1_expected = { self.v1_expected = {
@ -53,6 +53,10 @@ class TestRootController(api_base.FunctionalTest):
u'http://docs.openstack.org/developer' u'http://docs.openstack.org/developer'
'/magnum/dev/api-spec-v1.html', '/magnum/dev/api-spec-v1.html',
u'type': u'text/html', u'rel': u'describedby'}], u'type': u'text/html', u'rel': u'describedby'}],
u'stats': [{u'href': u'http://localhost/v1/stats/',
u'rel': u'self'},
{u'href': u'http://localhost/stats/',
u'rel': u'bookmark'}],
u'bays': [{u'href': u'http://localhost/v1/bays/', u'bays': [{u'href': u'http://localhost/v1/bays/',
u'rel': u'self'}, u'rel': u'self'},
{u'href': u'http://localhost/bays/', {u'href': u'http://localhost/bays/',

View File

@ -0,0 +1,130 @@
# 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 webtest.app import AppError
from magnum.tests.unit.api import base as api_base
from magnum.tests.unit.objects import utils as obj_utils
class TestStatsController(api_base.FunctionalTest):
def setUp(self):
self.base_headers = {'OpenStack-API-Version': 'container-infra 1.4'}
super(TestStatsController, self).setUp()
obj_utils.create_test_cluster_template(self.context)
def test_empty(self):
response = self.get_json('/stats', headers=self.base_headers)
expected = {u'clusters': 0, u'nodes': 0}
self.assertEqual(expected, response)
@mock.patch("magnum.common.policy.enforce")
@mock.patch("magnum.common.context.make_context")
def test_admin_get_all_stats(self, mock_context, mock_policy):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
obj_utils.create_test_cluster(self.context,
project_id=234,
uuid='uuid2')
response = self.get_json('/stats', headers=self.base_headers)
expected = {u'clusters': 2, u'nodes': 12}
self.assertEqual(expected, response)
@mock.patch("magnum.common.policy.enforce")
@mock.patch("magnum.common.context.make_context")
def test_admin_get_tenant_stats(self, mock_context, mock_policy):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
obj_utils.create_test_cluster(self.context,
project_id=234,
uuid='uuid2')
self.context.is_admin = True
response = self.get_json('/stats?project_id=234',
headers=self.base_headers)
expected = {u'clusters': 1, u'nodes': 6}
self.assertEqual(expected, response)
@mock.patch("magnum.common.policy.enforce")
@mock.patch("magnum.common.context.make_context")
def test_admin_get_invalid_tenant_stats(self, mock_context, mock_policy):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
obj_utils.create_test_cluster(self.context,
project_id=234,
uuid='uuid2')
self.context.is_admin = True
response = self.get_json('/stats?project_id=34',
headers=self.base_headers)
expected = {u'clusters': 0, u'nodes': 0}
self.assertEqual(expected, response)
def test_get_self_stats(self):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
obj_utils.create_test_cluster(self.context,
project_id=234,
uuid='uuid2',
node_count=5,
master_count=1)
headers = self.base_headers.copy()
headers['X-Project-Id'] = '234'
response = self.get_json('/stats',
headers=headers)
expected = {u'clusters': 1, u'nodes': 6}
self.assertEqual(expected, response)
def test_get_self_stats_without_param(self):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
obj_utils.create_test_cluster(self.context,
project_id=234,
uuid='uuid2',
node_count=5,
master_count=1)
headers = self.base_headers.copy()
headers['X-Project-Id'] = '234'
response = self.get_json('/stats',
headers=headers)
expected = {u'clusters': 1, u'nodes': 6}
self.assertEqual(expected, response)
def test_get_some_other_user_stats(self):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
obj_utils.create_test_cluster(self.context,
project_id=234,
uuid='uuid2',
node_count=5)
headers = self.base_headers.copy()
headers['X-Project-Id'] = '234'
self.assertRaises(AppError,
self.get_json,
'/stats?project_id=123',
headers=headers)
def test_get_invalid_type_stats(self):
obj_utils.create_test_cluster(self.context,
project_id=123,
uuid='uuid1')
self.assertRaises(AppError,
self.get_json,
'/stats?project_id=123&type=invalid',
headers=self.base_headers)

View File

@ -78,6 +78,26 @@ class DbClusterTestCase(base.DbTestCase):
self.dbapi.get_cluster_by_name, self.dbapi.get_cluster_by_name,
self.context, 'clusterone') self.context, 'clusterone')
def test_get_all_cluster_stats(self):
utils.create_test_cluster(
id=1, name='clusterone',
uuid=uuidutils.generate_uuid())
utils.create_test_cluster(
id=2, name='clustertwo',
uuid=uuidutils.generate_uuid())
ret = self.dbapi.get_cluster_stats(self.context)
self.assertEqual(ret, (2, 12))
def test_get_one_tenant_cluster_stats(self):
utils.create_test_cluster(
id=1, name='clusterone', project_id='proj1',
uuid=uuidutils.generate_uuid())
utils.create_test_cluster(
id=2, name='clustertwo', project_id='proj2',
uuid=uuidutils.generate_uuid())
ret = self.dbapi.get_cluster_stats(self.context, 'proj2')
self.assertEqual(ret, (1, 6))
def test_get_cluster_list(self): def test_get_cluster_list(self):
uuids = [] uuids = []
for i in range(1, 6): for i in range(1, 6):

View File

@ -355,12 +355,13 @@ class TestObject(test_base.TestCase, _TestObject):
# For more information on object version testing, read # For more information on object version testing, read
# http://docs.openstack.org/developer/magnum/objects.html # http://docs.openstack.org/developer/magnum/objects.html
object_data = { object_data = {
'Cluster': '1.11-d4566648f0158e45e43b0c0419814d1f', 'Cluster': '1.12-73881c0604a6c90d7ecfeb5abd380f7e',
'ClusterTemplate': '1.17-65a95ef932dd08800a83871eb3cf312b', 'ClusterTemplate': '1.17-65a95ef932dd08800a83871eb3cf312b',
'Certificate': '1.1-1924dc077daa844f0f9076332ef96815', 'Certificate': '1.1-1924dc077daa844f0f9076332ef96815',
'MyObj': '1.0-34c4b1aadefd177b13f9a2f894cc23cd', 'MyObj': '1.0-34c4b1aadefd177b13f9a2f894cc23cd',
'X509KeyPair': '1.2-d81950af36c59a71365e33ce539d24f9', 'X509KeyPair': '1.2-d81950af36c59a71365e33ce539d24f9',
'MagnumService': '1.0-2d397ec59b0046bd5ec35cd3e06efeca', 'MagnumService': '1.0-2d397ec59b0046bd5ec35cd3e06efeca',
'Stats': '1.0-73a1cd6e3c0294c932a66547faba216c',
} }

View File

@ -0,0 +1,6 @@
---
features:
- This release introduces 'stats' endpoint that provide the
total number of clusters and the total number of nodes
for the given tenant and also overall stats across all
the tenants.