Support quota API

Currently Aodh only supports global quota for projects and users, we
need quota API to manage quotas per project.

The new quota API only supports project quotas as the same with other
openstack services.

By default, only admin user can update the quota.

TODO(in subsequent patches):
- Check quota from DB when creating resources
- Doc
- Release note
- openstack CLI support

Change-Id: I9258ae4801edbc5289d890fe2e060964a73b216c
This commit is contained in:
Lingxian Kong 2020-01-28 18:28:35 +13:00
parent 58dd647097
commit 223178716e
13 changed files with 359 additions and 11 deletions

View File

@ -51,14 +51,15 @@ from aodh.storage import models
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
ALARM_API_OPTS = [ ALARM_API_OPTS = [
cfg.IntOpt('user_alarm_quota', cfg.IntOpt('user_alarm_quota',
deprecated_group='DEFAULT', deprecated_group='DEFAULT',
default=-1,
help='Maximum number of alarms defined for a user.' help='Maximum number of alarms defined for a user.'
), ),
cfg.IntOpt('project_alarm_quota', cfg.IntOpt('project_alarm_quota',
deprecated_group='DEFAULT', deprecated_group='DEFAULT',
default=-1,
help='Maximum number of alarms defined for a project.' help='Maximum number of alarms defined for a project.'
), ),
cfg.IntOpt('alarm_max_actions', cfg.IntOpt('alarm_max_actions',
@ -106,14 +107,14 @@ def is_over_quota(conn, project_id, user_id):
# Start by checking for user quota # Start by checking for user quota
user_alarm_quota = pecan.request.cfg.api.user_alarm_quota user_alarm_quota = pecan.request.cfg.api.user_alarm_quota
if user_alarm_quota is not None: if user_alarm_quota != -1:
user_alarms = conn.get_alarms(user_id=user_id) user_alarms = conn.get_alarms(user_id=user_id)
over_quota = len(user_alarms) >= user_alarm_quota over_quota = len(user_alarms) >= user_alarm_quota
# If the user quota isn't reached, we check for the project quota # If the user quota isn't reached, we check for the project quota
if not over_quota: if not over_quota:
project_alarm_quota = pecan.request.cfg.api.project_alarm_quota project_alarm_quota = pecan.request.cfg.api.project_alarm_quota
if project_alarm_quota is not None: if project_alarm_quota != -1:
project_alarms = conn.get_alarms(project_id=project_id) project_alarms = conn.get_alarms(project_id=project_id)
over_quota = len(project_alarms) >= project_alarm_quota over_quota = len(project_alarms) >= project_alarm_quota

View File

@ -77,6 +77,7 @@ class AdvEnum(wtypes.wsproperty):
class Base(wtypes.DynamicBase): class Base(wtypes.DynamicBase):
_wsme_attributes = []
@classmethod @classmethod
def from_db_model(cls, m): def from_db_model(cls, m):
@ -98,6 +99,14 @@ class Base(wtypes.DynamicBase):
if hasattr(self, k) and if hasattr(self, k) and
getattr(self, k) != wsme.Unset) getattr(self, k) != wsme.Unset)
def to_dict(self):
d = {}
for attr in self._wsme_attributes:
attr_val = getattr(self, attr.name)
if not isinstance(attr_val, wtypes.UnsetType):
d[attr.name] = attr_val
return d
class Query(Base): class Query(Base):
"""Query filter.""" """Query filter."""

View File

@ -0,0 +1,84 @@
# Copyright 2020 Catalyst Cloud LTD.
#
# 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_log import log
import pecan
from pecan import rest
import wsme
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from aodh.api.controllers.v2 import base
from aodh.api import rbac
LOG = log.getLogger(__name__)
ALLOWED_RESOURCES = ('alarms',)
class Quota(base.Base):
resource = wtypes.wsattr(wtypes.Enum(str, *ALLOWED_RESOURCES),
mandatory=True)
limit = wtypes.IntegerType(minimum=-1)
class Quotas(base.Base):
project_id = wsme.wsattr(wtypes.text, mandatory=True)
quotas = [Quota]
class QuotasController(rest.RestController):
"""Quota API controller."""
@wsme_pecan.wsexpose(Quotas, str, ignore_extra_args=True)
def get_all(self, project_id=None):
"""Get resource quotas of a project.
- If no project given, get requested user's quota.
- Admin user can get resource quotas of any project.
"""
request_project = pecan.request.headers.get('X-Project-Id')
project_id = project_id if project_id else request_project
is_admin = rbac.is_admin(pecan.request.headers)
if project_id != request_project and not is_admin:
raise base.ProjectNotAuthorized(project_id)
LOG.debug('Getting resource quotas for project %s', project_id)
db_quotas = pecan.request.storage.get_quotas(project_id=project_id)
if len(db_quotas) == 0:
project_alarm_quota = pecan.request.cfg.api.project_alarm_quota
quotas = [{'resource': 'alarms', 'limit': project_alarm_quota}]
db_quotas = pecan.request.storage.set_quotas(project_id, quotas)
quotas = [Quota.from_db_model(i) for i in db_quotas]
return Quotas(project_id=project_id, quotas=quotas)
@wsme_pecan.wsexpose(Quotas, body=Quotas, status_code=201)
def post(self, body):
"""Create or update quota."""
rbac.enforce('update_quotas', pecan.request.headers,
pecan.request.enforcer, {})
params = body.to_dict()
project_id = params['project_id']
input_quotas = []
for i in params.get('quotas', []):
input_quotas.append(i.to_dict())
db_quotas = pecan.request.storage.set_quotas(project_id, input_quotas)
quotas = [Quota.from_db_model(i) for i in db_quotas]
return Quotas(project_id=project_id, quotas=quotas)

View File

@ -21,6 +21,7 @@
from aodh.api.controllers.v2 import alarms from aodh.api.controllers.v2 import alarms
from aodh.api.controllers.v2 import capabilities from aodh.api.controllers.v2 import capabilities
from aodh.api.controllers.v2 import query from aodh.api.controllers.v2 import query
from aodh.api.controllers.v2 import quotas
class V2Controller(object): class V2Controller(object):
@ -29,3 +30,4 @@ class V2Controller(object):
alarms = alarms.AlarmsController() alarms = alarms.AlarmsController()
query = query.QueryController() query = query.QueryController()
capabilities = capabilities.CapabilitiesController() capabilities = capabilities.CapabilitiesController()
quotas = quotas.QuotasController()

View File

@ -155,6 +155,17 @@ rules = [
'method': 'POST' 'method': 'POST'
} }
] ]
),
policy.DocumentedRuleDefault(
name="telemetry:update_quotas",
check_str=RULE_CONTEXT_IS_ADMIN,
description='Update resources quotas for project.',
operations=[
{
'path': '/v2/quotas',
'method': 'POST'
}
]
) )
] ]

View File

@ -54,13 +54,6 @@ def enforce(policy_name, headers, enforcer, target):
'project_id': headers.get('X-Project-Id'), 'project_id': headers.get('X-Project-Id'),
} }
# TODO(sileht): add deprecation warning to be able to remove this:
# maintain backward compat with Juno and previous by allowing the action if
# there is no rule defined for it
rules = enforcer.rules.keys()
if rule_method not in rules:
return
if not enforcer.enforce(rule_method, target, credentials): if not enforcer.enforce(rule_method, target, credentials):
pecan.core.abort(status_code=403, pecan.core.abort(status_code=403,
detail='RBAC Authorization Failed') detail='RBAC Authorization Failed')

View File

@ -203,3 +203,15 @@ class Connection(object):
""" """
raise aodh.NotImplementedError('Clearing alarm history ' raise aodh.NotImplementedError('Clearing alarm history '
'not implemented') 'not implemented')
@staticmethod
def get_quotas(project_id):
"""Get resource quota for the given project."""
raise aodh.NotImplementedError('Getting resource quota not '
'implemented')
@staticmethod
def set_quotas(project_id, quotas):
"""Set resource quota for the given user."""
raise aodh.NotImplementedError('Setting resource quota not '
'implemented')

View File

@ -432,3 +432,46 @@ class Connection(base.Connection):
result = query.update(values, **update_args) result = query.update(values, **update_args)
return 0 != result return 0 != result
@staticmethod
def _row_to_quota_model(row):
return alarm_api_models.Quota(
project_id=row.project_id,
resource=row.resource,
limit=row.limit,
)
def _retrieve_quotas(self, query):
return [self._row_to_quota_model(x) for x in query.all()]
def get_quotas(self, project_id):
"""Get resource quota for the given project."""
filters = {'project_id': project_id}
session = self._engine_facade.get_session()
query = session.query(models.Quota).filter_by(**filters)
return self._retrieve_quotas(query)
def set_quotas(self, project_id, quotas):
"""Set resource quota for the given user."""
session = self._engine_facade.get_session()
with session.begin():
for q in quotas:
values = {
'project_id': project_id,
'resource': q['resource'],
}
quota = session.query(models.Quota).filter_by(**values).first()
if not quota:
new_quota = models.Quota(project_id=project_id,
resource=q['resource'],
limit=q['limit'])
session.add(new_quota)
else:
values['limit'] = q['limit']
quota.update(values.copy())
filters = {'project_id': project_id}
query = session.query(models.Quota).filter_by(**filters)
return self._retrieve_quotas(query)

View File

@ -151,3 +151,12 @@ class AlarmChange(base.Model):
project_id=project_id, project_id=project_id,
on_behalf_of=on_behalf_of, on_behalf_of=on_behalf_of,
timestamp=timestamp) timestamp=timestamp)
class Quota(base.Model):
def __init__(self, project_id, resource, limit):
base.Model.__init__(
self,
project_id=project_id,
resource=resource,
limit=limit)

View File

@ -0,0 +1,43 @@
# Copyright 2020 Catalyst Cloud Ltd.
#
# 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.
"""Add quota table
Revision ID: 007
Revises: 006
Create Date: 2020-01-28
"""
# revision identifiers, used by Alembic.
revision = '007'
down_revision = '006'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
'quota',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('resource', sa.String(length=50), nullable=False),
sa.Column('project_id', sa.String(length=128), nullable=False),
sa.Column('limit', sa.Integer, nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('project_id', 'resource'),
sa.Index(
'ix_quota_project_id_resource',
'project_id', 'resource'
)
)

View File

@ -16,8 +16,10 @@ SQLAlchemy models for aodh data.
import json import json
from oslo_utils import timeutils from oslo_utils import timeutils
from oslo_utils import uuidutils
import six import six
from sqlalchemy import Column, String, Index, Boolean, Text, DateTime import sqlalchemy as sa
from sqlalchemy import Column, String, Index, Boolean, Text, DateTime, Integer
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
@ -124,3 +126,17 @@ class AlarmChange(Base):
detail = Column(Text) detail = Column(Text)
timestamp = Column(TimestampUTC, default=lambda: timeutils.utcnow()) timestamp = Column(TimestampUTC, default=lambda: timeutils.utcnow())
severity = Column(String(50)) severity = Column(String(50))
class Quota(Base):
__tablename__ = 'quota'
__table_args__ = (
sa.UniqueConstraint('project_id', 'resource'),
Index('ix_%s_project_id_resource' % __tablename__,
'project_id', 'resource'),
)
id = Column(String(36), primary_key=True, default=uuidutils.generate_uuid)
project_id = Column(String(128))
resource = Column(String(50))
limit = Column(Integer)

View File

@ -91,6 +91,29 @@ class BaseTestCase(base.BaseTestCase):
else: else:
return root return root
def assert_single_item(self, items, **filters):
return self.assert_multiple_items(items, 1, **filters)[0]
def assert_multiple_items(self, items, count, **filters):
def _matches(item, **props):
for prop_name, prop_val in props.items():
v = (item[prop_name] if isinstance(item, dict)
else getattr(item, prop_name))
if v != prop_val:
return False
return True
filtered_items = list(
[item for item in items if _matches(item, **filters)]
)
found = len(filtered_items)
if found != count:
self.fail("Wrong number of items found [filters=%s, "
"expected=%s, found=%s]" % (filters, count, found))
return filtered_items
def _skip_decorator(func): def _skip_decorator(func):
@functools.wraps(func) @functools.wraps(func)

View File

@ -0,0 +1,102 @@
# Copyright 2020 Catalyst Cloud LTD.
#
# 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 copy
from oslo_utils import uuidutils
from aodh.tests.functional.api import v2
class TestQuotas(v2.FunctionalTest):
@classmethod
def setUpClass(cls):
super(TestQuotas, cls).setUpClass()
cls.project = uuidutils.generate_uuid()
cls.user = uuidutils.generate_uuid()
cls.auth_headers = {'X-User-Id': cls.user, 'X-Project-Id': cls.project}
cls.other_project = uuidutils.generate_uuid()
def test_get_quotas_by_user(self):
resp = self.get_json('/quotas', headers=self.auth_headers, status=200)
self.assertEqual(self.project, resp.get('project_id'))
self.assertTrue(len(resp.get('quotas', [])) > 0)
def test_get_project_quotas_by_user(self):
resp = self.get_json('/quotas?project_id=%s' % self.project,
headers=self.auth_headers, status=200)
self.assertEqual(self.project, resp.get('project_id'))
self.assertTrue(len(resp.get('quotas', [])) > 0)
def test_get_other_project_quotas_by_user_failed(self):
self.get_json(
'/quotas?project_id=%s' % self.other_project,
headers=self.auth_headers,
expect_errors=True,
status=401
)
def test_get_project_quotas_by_admin(self):
auth_headers = copy.copy(self.auth_headers)
auth_headers['X-Roles'] = 'admin'
resp = self.get_json('/quotas?project_id=%s' % self.other_project,
headers=auth_headers,
status=200)
self.assertEqual(self.other_project, resp.get('project_id'))
self.assertTrue(len(resp.get('quotas', [])) > 0)
def test_post_quotas_by_admin(self):
auth_headers = copy.copy(self.auth_headers)
auth_headers['X-Roles'] = 'admin'
resp = self.post_json(
'/quotas',
{
"project_id": self.other_project,
"quotas": [
{
"resource": "alarms",
"limit": 30
}
]
},
headers=auth_headers,
status=201
)
resp_json = resp.json
self.assertEqual(self.other_project, resp_json.get('project_id'))
self.assert_single_item(resp_json.get('quotas', []), resource='alarms',
limit=30)
def test_post_quotas_by_user_failed(self):
self.post_json(
'/quotas',
{
"project_id": self.other_project,
"quotas": [
{
"resource": "alarms",
"limit": 20
}
]
},
headers=self.auth_headers,
expect_errors=True,
status=403
)