Implement the GET and DELETE RESTAPI of resource plans

Change-Id: Ibe5e37801c7a89a1137cedea2a5f11261912b851
Closes-Bug: #1541729
This commit is contained in:
chenying 2016-02-15 11:31:34 +08:00
parent 2ae80a66a5
commit 78244d1e0a
10 changed files with 531 additions and 30 deletions

View File

@ -8,5 +8,6 @@
"plan:create": "rule:admin_or_owner",
"plan:update": "rule:admin_or_owner",
"plan:delete": "rule:admin_or_owner",
"plan:get": "rule:admin_or_owner"
"plan:get": "rule:admin_or_owner",
"plan:get_all": "rule:admin_or_owner"
}

View File

@ -12,18 +12,34 @@
"""The plans api."""
from oslo_config import cfg
from oslo_log import log as logging
import webob
from oslo_utils import uuidutils
from webob import exc
import smaug
from smaug.api import common
from smaug.api.openstack import wsgi
from smaug import exception
from smaug.i18n import _, _LI
from smaug import objects
from smaug.objects import base as objects_base
from smaug.operationengine import api as operationengine_api
import smaug.policy
from smaug import utils
import six
query_plan_filters_opt = cfg.ListOpt('query_plan_filters',
default=['name', 'status'],
help="Plan filter options which "
"non-admin user could use to "
"query plans. Default values "
"are: ['name', 'status']")
CONF = cfg.CONF
CONF.register_opt(query_plan_filters_opt)
LOG = logging.getLogger(__name__)
@ -45,7 +61,7 @@ def check_policy(context, action, target_obj=None):
smaug.policy.enforce(context, _action, target)
class PlanViewBuilder(object):
class PlanViewBuilder(common.ViewBuilder):
"""Model a server API response as a python dictionary."""
_collection_name = "plans"
@ -67,6 +83,36 @@ class PlanViewBuilder(object):
}
return plan_ref
def detail_list(self, request, plans, plan_count=None):
"""Detailed view of a list of plans."""
return self._list_view(self.detail, request, plans,
plan_count,
self._collection_name)
def _list_view(self, func, request, plans, plan_count,
coll_name=_collection_name):
"""Provide a view for a list of plans.
:param func: Function used to format the plan data
:param request: API request
:param plans: List of plans in dictionary format
:param plan_count: Length of the original list of plans
:param coll_name: Name of collection, used to generate the next link
for a pagination query
:returns: Plan data in dictionary format
"""
plans_list = [func(request, plan)['plan'] for plan in plans]
plans_links = self._get_collection_links(request,
plans,
coll_name,
plan_count)
plans_dict = {}
plans_dict['plans'] = plans_list
if plans_links:
plans_dict['plans_links'] = plans_links
return plans_dict
class PlansController(wsgi.Controller):
"""The Plans API controller for the OpenStack API."""
@ -80,9 +126,21 @@ class PlansController(wsgi.Controller):
def show(self, req, id):
"""Return data about the given plan."""
context = req.environ['smaug.context']
LOG.info(_LI("Show plan with id: %s"), id, context=context)
# TODO(chenying)
return {'Smaug': "Plans show test."}
if not uuidutils.is_uuid_like(id):
msg = _("Invalid plan id provided.")
raise exc.HTTPBadRequest(explanation=msg)
try:
plan = self._plan_get(context, id)
except exception.PlanNotFound as error:
raise exc.HTTPNotFound(explanation=error.msg)
LOG.info(_LI("Show plan request issued successfully."),
resource={'id': plan.id})
return self._view_builder.detail(req, plan)
def delete(self, req, id):
"""Delete a plan."""
@ -90,22 +148,86 @@ class PlansController(wsgi.Controller):
LOG.info(_LI("Delete plan with id: %s"), id, context=context)
# TODO(chenying)
return webob.Response(status_int=202)
try:
plan = self._plan_get(context, id)
except exception.PlanNotFound as error:
raise exc.HTTPNotFound(explanation=error.msg)
check_policy(context, 'delete', plan)
plan.destroy()
LOG.info(_LI("Delete plan request issued successfully."),
resource={'id': plan.id})
def index(self, req):
"""Returns a summary list of plans."""
"""Returns a list of plans, transformed through view builder."""
context = req.environ['smaug.context']
# TODO(chenying)
LOG.info(_LI("Show plan list"), context=context)
return {'plan': "Plans index test."}
params = req.params.copy()
marker, limit, offset = common.get_pagination_params(params)
sort_keys, sort_dirs = common.get_sort_params(params)
filters = params
def detail(self, req):
"""Returns a detailed list of plans."""
utils.remove_invalid_filter_options(context,
filters,
self._get_plan_filter_options())
# TODO(chenying)
utils.check_filters(filters)
plans = self._get_all(context, marker, limit,
sort_keys=sort_keys,
sort_dirs=sort_dirs,
filters=filters,
offset=offset)
return {'plan': "Plans detail test."}
retval_plans = self._view_builder.detail_list(req, plans)
LOG.info(_LI("Show plan list request issued successfully."))
return retval_plans
def _get_all(self, context, marker=None, limit=None, sort_keys=None,
sort_dirs=None, filters=None, offset=None):
check_policy(context, 'get_all')
if filters is None:
filters = {}
all_tenants = utils.get_bool_param('all_tenants', filters)
try:
if limit is not None:
limit = int(limit)
if limit < 0:
msg = _('limit param must be positive')
raise exception.InvalidInput(reason=msg)
except ValueError:
msg = _('limit param must be an integer')
raise exception.InvalidInput(reason=msg)
if filters:
LOG.debug("Searching by: %s.", six.text_type(filters))
if context.is_admin and all_tenants:
# Need to remove all_tenants to pass the filtering below.
del filters['all_tenants']
plans = objects.PlanList.get_all(context, marker, limit,
sort_keys=sort_keys,
sort_dirs=sort_dirs,
filters=filters,
offset=offset)
else:
plans = objects.PlanList.get_all_by_project(
context, context.project_id, marker, limit,
sort_keys=sort_keys, sort_dirs=sort_dirs, filters=filters,
offset=offset)
LOG.info(_LI("Get all plans completed successfully."))
return plans
def _get_plan_filter_options(self):
"""Return plan search options allowed by non-admin."""
return CONF.query_plan_filters
def create(self, req, body):
"""Creates a new plan."""
@ -153,6 +275,10 @@ class PlansController(wsgi.Controller):
msg = _("Missing required element '%s' in request body") % 'plan'
raise exc.HTTPBadRequest(explanation=msg)
if not uuidutils.is_uuid_like(id):
msg = _("Invalid plan id provided.")
raise exc.HTTPBadRequest(explanation=msg)
plan = body['plan']
update_dict = {}
@ -186,6 +312,10 @@ class PlansController(wsgi.Controller):
return retval
def _plan_get(self, context, plan_id):
if not uuidutils.is_uuid_like(plan_id):
msg = _("Invalid plan id provided.")
raise exc.HTTPBadRequest(explanation=msg)
plan = objects.Plan.get_by_id(context, plan_id)
try:
check_policy(context, 'get', plan)

View File

@ -26,7 +26,7 @@ class APIRouter(wsgi_common.Router):
scheduled_operation_resources = scheduled_operations.create_resource()
mapper.resource("plan", "plans",
controller=plans_resources,
collection={'detail': 'GET'},
collection={},
member={'action': 'POST'})
mapper.resource("scheduled_operation", "scheduled_operations",
controller=scheduled_operation_resources,

View File

@ -391,3 +391,14 @@ def plan_get_all(context, marker, limit, sort_keys=None, sort_dirs=None,
return IMPL.plan_get_all(context, marker, limit, sort_keys=sort_keys,
sort_dirs=sort_dirs, filters=filters,
offset=offset)
def plan_get_all_by_project(context, project_id, marker, limit,
sort_keys=None, sort_dirs=None, filters=None,
offset=None):
"""Get all plans belonging to a project."""
return IMPL.plan_get_all_by_project(context, project_id, marker, limit,
sort_keys=sort_keys,
sort_dirs=sort_dirs,
filters=filters,
offset=offset)

View File

@ -24,9 +24,12 @@ from oslo_db import api as oslo_db_api
from oslo_db import exception as db_exc
from oslo_db import options
from oslo_db.sqlalchemy import session as db_session
from oslo_db.sqlalchemy import utils as sqlalchemyutils
from oslo_log import log as logging
from oslo_utils import timeutils
from sqlalchemy import or_
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import RelationshipProperty
from sqlalchemy.sql.expression import literal_column
from sqlalchemy.sql import func
@ -683,3 +686,265 @@ def plan_resources_update(context, plan_id, resources):
return _plan_resources_update(context,
plan_id,
resources)
@require_admin_context
def plan_get_all(context, marker, limit, sort_keys=None, sort_dirs=None,
filters=None, offset=None):
"""Retrieves all plans.
If no sort parameters are specified then the returned plans are sorted
first by the 'created_at' key and then by the 'id' key in descending
order.
:param context: context to query under
:param marker: the last item of the previous page, used to determine the
next page of results to return
:param limit: maximum number of items to return
:param sort_keys: list of attributes by which results should be sorted,
paired with corresponding item in sort_dirs
:param sort_dirs: list of directions in which results should be sorted,
paired with corresponding item in sort_keys
:param filters: dictionary of filters; values that are in lists, tuples,
or sets cause an 'IN' operation, while exact matching
is used for other values, see _process_plan_filters
function for more information
:returns: list of matching plans
"""
session = get_session()
with session.begin():
# Generate the query
query = _generate_paginate_query(context, session, marker, limit,
sort_keys, sort_dirs, filters, offset)
# No plans would match, return empty list
if query is None:
return []
return query.all()
@require_context
def plan_get_all_by_project(context, project_id, marker, limit,
sort_keys=None, sort_dirs=None, filters=None,
offset=None):
"""Retrieves all plans in a project.
If no sort parameters are specified then the returned plans are sorted
first by the 'created_at' key and then by the 'id' key in descending
order.
:param context: context to query under
:param project_id: project for all plans being retrieved
:param marker: the last item of the previous page, used to determine the
next page of results to return
:param limit: maximum number of items to return
:param sort_keys: list of attributes by which results should be sorted,
paired with corresponding item in sort_dirs
:param sort_dirs: list of directions in which results should be sorted,
paired with corresponding item in sort_keys
:param filters: dictionary of filters; values that are in lists, tuples,
or sets cause an 'IN' operation, while exact matching
is used for other values, see _process_plan_filters
function for more information
:returns: list of matching plans
"""
session = get_session()
with session.begin():
authorize_project_context(context, project_id)
# Add in the project filter without modifying the given filters
filters = filters.copy() if filters else {}
filters['project_id'] = project_id
# Generate the query
query = _generate_paginate_query(context, session, marker, limit,
sort_keys, sort_dirs, filters, offset)
# No plans would match, return empty list
if query is None:
return []
return query.all()
def _process_plan_filters(query, filters):
"""Common filter processing for Plan queries.
Filter values that are in lists, tuples, or sets cause an 'IN' operator
to be used, while exact matching ('==' operator) is used for other values.
A 'metadata' filter key must correspond to a dictionary value of metadata
key-value pairs.
:param query: Model query to use
:param filters: dictionary of filters
:returns: updated query or None
"""
filters = filters.copy()
# Apply exact match filters for everything else, ensure that the
# filter value exists on the model
for key in filters.keys():
# metadata is unique, must be a dict
if key == 'resources':
if not isinstance(filters[key], dict):
LOG.debug("'metadata' filter value is not valid.")
return None
continue
try:
column_attr = getattr(models.Plan, key)
# Do not allow relationship properties since those require
# schema specific knowledge
prop = getattr(column_attr, 'property')
if isinstance(prop, RelationshipProperty):
LOG.debug(("'%s' filter key is not valid, "
"it maps to a relationship."), key)
return None
except AttributeError:
LOG.debug("'%s' filter key is not valid.", key)
return None
# Holds the simple exact matches
filter_dict = {}
# Iterate over all filters, special case the filter if necessary
for key, value in filters.items():
if key == 'resources':
col_attr = getattr(models.Plan, 'continue')
for k, v in value.items():
query = query.filter(or_(col_attr.any(key=k, value=v)))
elif isinstance(value, (list, tuple, set, frozenset)):
# Looking for values in a list; apply to query directly
column_attr = getattr(models.Plan, key)
query = query.filter(column_attr.in_(value))
else:
# OK, simple exact match; save for later
filter_dict[key] = value
# Apply simple exact matches
if filter_dict:
query = query.filter_by(**filter_dict)
return query
###############################
PAGINATION_HELPERS = {
models.Plan: (_plan_get_query, _process_plan_filters, _plan_get)
}
###############################
def _generate_paginate_query(context, session, marker, limit, sort_keys,
sort_dirs, filters, offset=None,
paginate_type=models.Plan):
"""Generate the query to include the filters and the paginate options.
Returns a query with sorting / pagination criteria added or None
if the given filters will not yield any results.
:param context: context to query under
:param session: the session to use
:param marker: the last item of the previous page; we returns the next
results after this value.
:param limit: maximum number of items to return
:param sort_keys: list of attributes by which results should be sorted,
paired with corresponding item in sort_dirs
:param sort_dirs: list of directions in which results should be sorted,
paired with corresponding item in sort_keys
:param filters: dictionary of filters; values that are in lists, tuples,
or sets cause an 'IN' operation, while exact matching
is used for other values, see _process_plan_filters
function for more information
:param offset: number of items to skip
:param paginate_type: type of pagination to generate
:returns: updated query or None
"""
get_query, process_filters, get = PAGINATION_HELPERS[paginate_type]
sort_keys, sort_dirs = process_sort_params(sort_keys,
sort_dirs,
default_dir='desc')
query = get_query(context, session=session)
if filters:
query = process_filters(query, filters)
if query is None:
return None
marker_object = None
if marker is not None:
marker_object = get(context, marker, session)
return sqlalchemyutils.paginate_query(query, paginate_type, limit,
sort_keys,
marker=marker_object,
sort_dirs=sort_dirs)
def process_sort_params(sort_keys, sort_dirs, default_keys=None,
default_dir='asc'):
"""Process the sort parameters to include default keys.
Creates a list of sort keys and a list of sort directions. Adds the default
keys to the end of the list if they are not already included.
When adding the default keys to the sort keys list, the associated
direction is:
1) The first element in the 'sort_dirs' list (if specified), else
2) 'default_dir' value (Note that 'asc' is the default value since this is
the default in sqlalchemy.utils.paginate_query)
:param sort_keys: List of sort keys to include in the processed list
:param sort_dirs: List of sort directions to include in the processed list
:param default_keys: List of sort keys that need to be included in the
processed list, they are added at the end of the list
if not already specified.
:param default_dir: Sort direction associated with each of the default
keys that are not supplied, used when they are added
to the processed list
:returns: list of sort keys, list of sort directions
:raise exception.InvalidInput: If more sort directions than sort keys
are specified or if an invalid sort
direction is specified
"""
if default_keys is None:
default_keys = ['created_at', 'id']
# Determine direction to use for when adding default keys
if sort_dirs and len(sort_dirs):
default_dir_value = sort_dirs[0]
else:
default_dir_value = default_dir
# Create list of keys (do not modify the input list)
if sort_keys:
result_keys = list(sort_keys)
else:
result_keys = []
# If a list of directions is not provided, use the default sort direction
# for all provided keys.
if sort_dirs:
result_dirs = []
# Verify sort direction
for sort_dir in sort_dirs:
if sort_dir not in ('asc', 'desc'):
msg = _("Unknown sort direction, must be 'desc' or 'asc'.")
raise exception.InvalidInput(reason=msg)
result_dirs.append(sort_dir)
else:
result_dirs = [default_dir_value for _sort_key in result_keys]
# Ensure that the key and direction length match
while len(result_dirs) < len(result_keys):
result_dirs.append(default_dir_value)
# Unless more direction are specified, which is an error
if len(result_dirs) > len(result_keys):
msg = _("Sort direction array size exceeds sort key array size.")
raise exception.InvalidInput(reason=msg)
# Ensure defaults are included
for key in default_keys:
if key not in result_keys:
result_keys.append(key)
result_dirs.append(default_dir_value)
return result_keys, result_dirs

View File

@ -146,6 +146,10 @@ class Invalid(SmaugException):
code = 400
class InvalidParameterValue(Invalid):
message = _("%(err)s")
class InvalidInput(Invalid):
message = _("Invalid input received: %(reason)s")

View File

@ -152,3 +152,15 @@ class PlanList(base.ObjectListBase, base.SmaugObject):
expected_attrs = ['resources']
return base.obj_make_list(context, cls(context), objects.Plan,
plans, expected_attrs=expected_attrs)
@base.remotable_classmethod
def get_all_by_project(cls, context, project_id, marker, limit,
sort_keys=None, sort_dirs=None, filters=None,
offset=None):
plans = db.plan_get_all_by_project(context, project_id, marker,
limit, sort_keys=sort_keys,
sort_dirs=sort_dirs,
filters=filters, offset=offset)
expected_attrs = ['resources']
return base.obj_make_list(context, cls(context), objects.Plan,
plans, expected_attrs=expected_attrs)

View File

@ -25,7 +25,7 @@ CONF = cfg.CONF
DEFAULT_NAME = 'My 3 tier application'
DEFAULT_PROVIDER_ID = 'efc6a88b-9096-4bb6-8634-cda182a6e12a'
DEFAULT_STATUS = 'started'
DEFAULT_STATUS = 'suspended'
DEFAULT_PROJECT_ID = '39bb894794b741e982bd26144d2949f6'
DEFAULT_RESOURCES = [{'id': 'key1',
"type": "value1"}]
@ -92,15 +92,17 @@ class PlanApiTest(base.TestCase):
plan = self._plan_in_request_body()
body = {"planxx": plan}
req = fakes.HTTPRequest.blank('/v1/plans')
self.assertRaises(exc.HTTPBadRequest, self.controller.update,
req, "1", body)
self.assertRaises(
exc.HTTPBadRequest, self.controller.update,
req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body)
def test_plan_update_InvalidId(self):
plan = self._plan_in_request_body()
body = {"plan": plan}
req = fakes.HTTPRequest.blank('/v1/plans')
self.assertRaises(exc.HTTPNotFound, self.controller.update,
req, "1", body)
self.assertRaises(
exc.HTTPNotFound, self.controller.update,
req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body)
def test_plan_update_InvalidResources(self):
plan = self._plan_in_request_body(name=DEFAULT_NAME,
@ -110,15 +112,55 @@ class PlanApiTest(base.TestCase):
resources=[{'key1': 'value1'}])
body = {"plan": plan}
req = fakes.HTTPRequest.blank('/v1/plans')
self.assertRaises(exception.InvalidInput, self.controller.update,
req, "1", body)
self.assertRaises(
exception.InvalidInput, self.controller.update,
req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body)
@mock.patch(
'smaug.api.v1.plans.PlansController._get_all')
def test_plan_list_detail(self, moak_get_all):
req = fakes.HTTPRequest.blank('/v1/plans')
self.controller.index(req)
self.assertTrue(moak_get_all.called)
@mock.patch(
'smaug.api.v1.plans.PlansController._plan_get')
def test_plan_show(self, moak_plan_get):
req = fakes.HTTPRequest.blank('/v1/plans')
self.controller.\
show(req, '2a9ce1f3-cc1a-4516-9435-0ebb13caa398')
self.assertTrue(moak_plan_get.called)
def test_plan_show_Invalid(self):
req = fakes.HTTPRequest.blank('/v1/plans/1')
self.assertRaises(
exc.HTTPBadRequest, self.controller.show,
req, "1")
@mock.patch(
'smaug.api.v1.plans.PlansController._plan_get')
def test_plan_delete(self, moak_plan_get):
req = fakes.HTTPRequest.blank('/v1/plans')
self.controller.\
show(req, '2a9ce1f3-cc1a-4516-9435-0ebb13caa398')
self.assertTrue(moak_plan_get.called)
def test_plan_delete_Invalid(self):
req = fakes.HTTPRequest.blank('/v1/plans/1')
self.assertRaises(
exc.HTTPBadRequest, self.controller.show,
req, "1")
@mock.patch(
'smaug.api.v1.plans.check_policy')
@mock.patch(
'smaug.api.v1.plans.PlansController._plan_get')
def test_plan_update_InvalidStatus(self, mock_plan_get, mock_check_policy):
plan = self._plan_in_request_body()
plan = self._plan_in_request_body(name=DEFAULT_NAME,
provider_id=DEFAULT_PROVIDER_ID,
status="started",
project_id=DEFAULT_PROJECT_ID,
resources=DEFAULT_RESOURCES)
body = {"plan": plan}
req = fakes.HTTPRequest.blank('/v1/plans')
mock_plan_get.return_value = plan

View File

@ -28,10 +28,3 @@ class PlansRouterTestCase(base.TestCase):
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)
def test_plans_detail(self):
req = fakes.HTTPRequest.blank('/fakeproject/plans/detail')
req.method = 'GET'
req.content_type = 'application/json'
response = req.get_response(self.app)
self.assertEqual(200, response.status_int)

View File

@ -11,11 +11,14 @@
# under the License.
"""Utilities and helper functions."""
import ast
import os
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import strutils
from oslo_utils import timeutils
import six
from smaug import exception
@ -78,3 +81,43 @@ def service_is_up(service):
elapsed = (timeutils.utcnow(with_timezone=True) -
last_heartbeat).total_seconds()
return abs(elapsed) <= CONF.service_down_time
def remove_invalid_filter_options(context, filters,
allowed_search_options):
"""Remove search options that are not valid for non-admin API/context."""
if context.is_admin:
# Allow all options
return
# Otherwise, strip out all unknown options
unknown_options = [opt for opt in filters
if opt not in allowed_search_options]
bad_options = ", ".join(unknown_options)
LOG.debug("Removing options '%s' from query.", bad_options)
for opt in unknown_options:
del filters[opt]
def check_filters(filters):
for k, v in six.iteritems(filters):
try:
filters[k] = ast.literal_eval(v)
except (ValueError, SyntaxError):
LOG.debug('Could not evaluate value %s, assuming string', v)
def is_valid_boolstr(val):
"""Check if the provided string is a valid bool string or not."""
val = str(val).lower()
return val in ('true', 'false', 'yes', 'no', 'y', 'n', '1', '0')
def get_bool_param(param_string, params):
param = params.get(param_string, False)
if not is_valid_boolstr(param):
msg = _('Value %(param)s for %(param_string)s is not a '
'boolean.') % {'param': param, 'param_string': param_string}
raise exception.InvalidParameterValue(err=msg)
return strutils.bool_from_string(param, strict=True)