Delete the placement code

This finalizes the removal of the placement code from nova.
This change primarily removes code and makes fixes to cmd,
test and migration tooling to adapt to the removal.

Placement tests and documention were already removed in
early patches.

A database migration that calls
consumer_obj.create_incomplete_consumers in nova-manage has been
removed.

A functional test which confirms the default incomplete
consumer user and project id has been changes so its its use of
conf.placement.incomplete_* (now removed) is replaced with a
constant. The placement server, running in the functional
test, provides its own config.

placement-related configuration is updated to only register those
opts which are relevant on the nova side. This mostly means
ksa-related opts. placement-database configuration is removed
from nova/conf/database.

tox.ini is updated to remove the group_regex required by the
placement gabbi tests. This should probably have gone when the
placement functional tests went, but was overlooked.

A release note is added which describes that this is cleanup,
the main action already happened, but points people to the
nova to placement upgrade instructions in case they haven't
done it yet.

Change-Id: I4181f39dea7eb10b84e6f5057938767b3e422aff
changes/15/618215/37
Chris Dent 4 years ago
parent 760fc2de32
commit 70a2879b2c

1
.gitignore vendored

@ -47,7 +47,6 @@ nova/vcsversion.py
tools/conf/nova.conf*
doc/source/_static/nova.conf.sample
doc/source/_static/nova.policy.yaml.sample
doc/source/_static/placement.policy.yaml.sample
# Files created by releasenotes build
releasenotes/build

@ -55,7 +55,6 @@ sample_config_basename = '_static/nova'
policy_generator_config_file = [
('../../etc/nova/nova-policy-generator.conf', '_static/nova'),
('../../etc/nova/placement-policy-generator.conf', '_static/placement')
]
actdiag_html_image_format = 'SVG'

@ -1,102 +0,0 @@
# 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 keystonemiddleware import auth_token
from oslo_log import log as logging
from oslo_middleware import request_id
import webob.dec
import webob.exc
from nova.api.openstack.placement import context
LOG = logging.getLogger(__name__)
class Middleware(object):
def __init__(self, application, **kwargs):
self.application = application
# NOTE(cdent): Only to be used in tests where auth is being faked.
class NoAuthMiddleware(Middleware):
"""Require a token if one isn't present."""
def __init__(self, application):
self.application = application
@webob.dec.wsgify
def __call__(self, req):
if req.environ['PATH_INFO'] == '/':
return self.application
if 'X-Auth-Token' not in req.headers:
return webob.exc.HTTPUnauthorized()
token = req.headers['X-Auth-Token']
user_id, _sep, project_id = token.partition(':')
project_id = project_id or user_id
if user_id == 'admin':
roles = ['admin']
else:
roles = []
req.headers['X_USER_ID'] = user_id
req.headers['X_TENANT_ID'] = project_id
req.headers['X_ROLES'] = ','.join(roles)
return self.application
class PlacementKeystoneContext(Middleware):
"""Make a request context from keystone headers."""
@webob.dec.wsgify
def __call__(self, req):
req_id = req.environ.get(request_id.ENV_REQUEST_ID)
ctx = context.RequestContext.from_environ(
req.environ, request_id=req_id)
if ctx.user_id is None and req.environ['PATH_INFO'] != '/':
LOG.debug("Neither X_USER_ID nor X_USER found in request")
return webob.exc.HTTPUnauthorized()
req.environ['placement.context'] = ctx
return self.application
class PlacementAuthProtocol(auth_token.AuthProtocol):
"""A wrapper on Keystone auth_token middleware.
Does not perform verification of authentication tokens
for root in the API.
"""
def __init__(self, app, conf):
self._placement_app = app
super(PlacementAuthProtocol, self).__init__(app, conf)
def __call__(self, environ, start_response):
if environ['PATH_INFO'] == '/':
return self._placement_app(environ, start_response)
return super(PlacementAuthProtocol, self).__call__(
environ, start_response)
def filter_factory(global_conf, **local_conf):
conf = global_conf.copy()
conf.update(local_conf)
def auth_filter(app):
return PlacementAuthProtocol(app, conf)
return auth_filter

@ -1,52 +0,0 @@
# 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_context import context
from oslo_db.sqlalchemy import enginefacade
from nova.api.openstack.placement import exception
from nova.api.openstack.placement import policy
@enginefacade.transaction_context_provider
class RequestContext(context.RequestContext):
def can(self, action, target=None, fatal=True):
"""Verifies that the given action is valid on the target in this
context.
:param action: string representing the action to be checked.
:param target: As much information about the object being operated on
as possible. The target argument should be a dict instance or an
instance of a class that fully supports the Mapping abstract base
class and deep copying. For object creation this should be a
dictionary representing the location of the object e.g.
``{'project_id': context.project_id}``. If None, then this default
target will be considered::
{'project_id': self.project_id, 'user_id': self.user_id}
:param fatal: if False, will return False when an
exception.PolicyNotAuthorized occurs.
:raises nova.api.openstack.placement.exception.PolicyNotAuthorized:
if verification fails and fatal is True.
:return: returns a non-False value (not necessarily "True") if
authorized and False if not authorized and fatal is False.
"""
if target is None:
target = {'project_id': self.project_id,
'user_id': self.user_id}
try:
return policy.authorize(self, action, target)
except exception.PolicyNotAuthorized:
if fatal:
raise
return False

@ -1,48 +0,0 @@
# 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.
"""Database context manager for placement database connection, kept in its
own file so the nova db_api (which has cascading imports) is not imported.
"""
from oslo_db.sqlalchemy import enginefacade
from oslo_log import log as logging
from nova.utils import run_once
placement_context_manager = enginefacade.transaction_context()
LOG = logging.getLogger(__name__)
def _get_db_conf(conf_group):
return dict(conf_group.items())
@run_once("TransactionFactory already started, not reconfiguring.",
LOG.warning)
def configure(conf):
# If [placement_database]/connection is not set in conf, then placement
# data will be stored in the nova_api database.
if conf.placement_database.connection is None:
placement_context_manager.configure(
**_get_db_conf(conf.api_database))
else:
placement_context_manager.configure(
**_get_db_conf(conf.placement_database))
def get_placement_engine():
return placement_context_manager.writer.get_engine()
@enginefacade.transaction_context_provider
class DbContext(object):
"""Stub class for db session handling outside of web requests."""

@ -1,120 +0,0 @@
# 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.
"""Deployment handling for Placmenent API."""
from microversion_parse import middleware as mp_middleware
import oslo_middleware
from oslo_middleware import cors
from nova.api.openstack.placement import auth
from nova.api.openstack.placement import db_api
from nova.api.openstack.placement import fault_wrap
from nova.api.openstack.placement import handler
from nova.api.openstack.placement import microversion
from nova.api.openstack.placement.objects import resource_provider
from nova.api.openstack.placement import requestlog
from nova.api.openstack.placement import util
# TODO(cdent): NAME points to the config project being used, so for
# now this is "nova" but we probably want "placement" eventually.
NAME = "nova"
def deploy(conf):
"""Assemble the middleware pipeline leading to the placement app."""
if conf.api.auth_strategy == 'noauth2':
auth_middleware = auth.NoAuthMiddleware
else:
# Do not use 'oslo_config_project' param here as the conf
# location may have been overridden earlier in the deployment
# process with OS_PLACEMENT_CONFIG_DIR in wsgi.py.
auth_middleware = auth.filter_factory(
{}, oslo_config_config=conf)
# Pass in our CORS config, if any, manually as that's a)
# explicit, b) makes testing more straightfoward, c) let's
# us control the use of cors by the presence of its config.
conf.register_opts(cors.CORS_OPTS, 'cors')
if conf.cors.allowed_origin:
cors_middleware = oslo_middleware.CORS.factory(
{}, **conf.cors)
else:
cors_middleware = None
context_middleware = auth.PlacementKeystoneContext
req_id_middleware = oslo_middleware.RequestId
microversion_middleware = mp_middleware.MicroversionMiddleware
fault_middleware = fault_wrap.FaultWrapper
request_log = requestlog.RequestLog
application = handler.PlacementHandler()
# configure microversion middleware in the old school way
application = microversion_middleware(
application, microversion.SERVICE_TYPE, microversion.VERSIONS,
json_error_formatter=util.json_error_formatter)
# NOTE(cdent): The ordering here is important. The list is ordered
# from the inside out. For a single request req_id_middleware is called
# first and microversion_middleware last. Then the request is finally
# passed to the application (the PlacementHandler). At that point
# the response ascends the middleware in the reverse of the
# order the request went in. This order ensures that log messages
# all see the same contextual information including request id and
# authentication information.
for middleware in (fault_middleware,
request_log,
context_middleware,
auth_middleware,
cors_middleware,
req_id_middleware,
):
if middleware:
application = middleware(application)
# NOTE(mriedem): Ignore scope check UserWarnings from oslo.policy.
if not conf.oslo_policy.enforce_scope:
import warnings
warnings.filterwarnings('ignore',
message="Policy .* failed scope check",
category=UserWarning)
return application
def update_database():
"""Do any database updates required at process boot time, such as
updating the traits table.
"""
ctx = db_api.DbContext()
resource_provider.ensure_trait_sync(ctx)
resource_provider.ensure_rc_cache(ctx)
# NOTE(cdent): Althought project_name is no longer used because of the
# resolution of https://bugs.launchpad.net/nova/+bug/1734491, loadapp()
# is considered a public interface for the creation of a placement
# WSGI app so must maintain its interface. The canonical placement WSGI
# app is created by init_application in wsgi.py, but this is not
# required and in fact can be limiting. loadapp() may be used from
# fixtures or arbitrary WSGI frameworks and loaders.
def loadapp(config, project_name=NAME):
"""WSGI application creator for placement.
:param config: An olso_config.cfg.ConfigOpts containing placement
configuration.
:param project_name: oslo_config project name. Ignored, preserved for
backwards compatibility
"""
application = deploy(config)
update_database()
return application

@ -1,94 +0,0 @@
# 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.
"""Call any URI in the placement service directly without real HTTP.
This is useful for those cases where processes wish to manipulate the
Placement datastore but do not want to run Placement as a long running
service. A PlacementDirect context manager is provided. Within that
HTTP requests may be made as normal but they will not actually traverse
a real socket.
"""
from keystoneauth1 import adapter
from keystoneauth1 import session
import mock
from oslo_utils import uuidutils
import requests
from wsgi_intercept import interceptor
from nova.api.openstack.placement import deploy
class PlacementDirect(interceptor.RequestsInterceptor):
"""Provide access to the placement service without real HTTP.
wsgi-intercept is used to provide a keystoneauth1 Adapter that has access
to an in-process placement service. This provides access to making changes
to the placement database without requiring HTTP over the network - it
remains in-process.
Authentication to the service is turned off; admin access is assumed.
Access is provided via a context manager which is responsible for
turning the wsgi-intercept on and off, and setting and removing
mocks required to keystoneauth1 to work around endpoint discovery.
Example::
with PlacementDirect(cfg.CONF, latest_microversion=True) as client:
allocations = client.get('/allocations/%s' % consumer)
:param conf: An oslo config with the options used to configure
the placement service (notably database connection
string).
:param latest_microversion: If True, API requests will use the latest
microversion if not otherwise specified. If
False (the default), the base microversion is
the default.
"""
def __init__(self, conf, latest_microversion=False):
conf.set_override('auth_strategy', 'noauth2', group='api')
app = lambda: deploy.loadapp(conf)
self.url = 'http://%s/placement' % str(uuidutils.generate_uuid())
# Supply our own session so the wsgi-intercept can intercept
# the right thing.
request_session = requests.Session()
headers = {
'x-auth-token': 'admin',
}
# TODO(efried): See below
if latest_microversion:
headers['OpenStack-API-Version'] = 'placement latest'
self.adapter = adapter.Adapter(
session.Session(auth=None, session=request_session,
additional_headers=headers),
service_type='placement', raise_exc=False)
# TODO(efried): Figure out why this isn't working:
# default_microversion='latest' if latest_microversion else None)
self._mocked_endpoint = mock.patch(
'keystoneauth1.session.Session.get_endpoint',
new=mock.Mock(return_value=self.url))
super(PlacementDirect, self).__init__(app, url=self.url)
def __enter__(self):
"""Start the wsgi-intercept interceptor and keystone endpoint mock.
A no auth ksa Adapter is provided to the context being managed.
"""
super(PlacementDirect, self).__enter__()
self._mocked_endpoint.start()
return self.adapter
def __exit__(self, *exc):
self._mocked_endpoint.stop()
return super(PlacementDirect, self).__exit__(*exc)

@ -1,48 +0,0 @@
# 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.
"""Error code symbols to be used in structured JSON error responses.
These are strings to be used in the 'code' attribute, as described by
the API guideline on `errors`_.
There must be only one instance of any string value and it should have
only one associated constant SYMBOL.
In a WSGI handler (representing the sole handler for an HTTP method and
URI) each error condition should get a separate error code. Reusing an
error code in a different handler is not just acceptable, but useful.
For example 'placement.inventory.inuse' is meaningful and correct in both
``PUT /resource_providers/{uuid}/inventories`` and ``DELETE`` on the same
URI.
.. _errors: http://specs.openstack.org/openstack/api-wg/guidelines/errors.html
"""
# NOTE(cdent): This is the simplest thing that can possibly work, for now.
# If it turns out we want to automate this, or put different resources in
# different files, or otherwise change things, that's fine. The only thing
# that needs to be maintained as the same are the strings that API end
# users use. How they are created is completely fungible.
# Do not change the string values. Once set, they are set.
# Do not reuse string values. There should be only one symbol for any
# value.
DEFAULT = 'placement.undefined_code'
INVENTORY_INUSE = 'placement.inventory.inuse'
CONCURRENT_UPDATE = 'placement.concurrent_update'
DUPLICATE_NAME = 'placement.duplicate_name'
PROVIDER_IN_USE = 'placement.resource_provider.inuse'
PROVIDER_CANNOT_DELETE_PARENT = (
'placement.resource_provider.cannot_delete_parent')
RESOURCE_PROVIDER_NOT_FOUND = 'placement.resource_provider.not_found'

@ -1,207 +0,0 @@
# 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.
"""Exceptions for use in the Placement API."""
# NOTE(cdent): The exceptions are copied from nova.exception, where they
# were originally used. To prepare for extracting placement to its own
# repository we wish to no longer do that. Instead, exceptions used by
# placement should be in the placement hierarchy.
from oslo_log import log as logging
from nova.i18n import _
LOG = logging.getLogger(__name__)
class _BaseException(Exception):
"""Base Exception
To correctly use this class, inherit from it and define
a 'msg_fmt' property. That msg_fmt will get printf'd
with the keyword arguments provided to the constructor.
"""
msg_fmt = _("An unknown exception occurred.")
def __init__(self, message=None, **kwargs):
self.kwargs = kwargs
if not message:
try:
message = self.msg_fmt % kwargs
except Exception:
# NOTE(melwitt): This is done in a separate method so it can be
# monkey-patched during testing to make it a hard failure.
self._log_exception()
message = self.msg_fmt
self.message = message
super(_BaseException, self).__init__(message)
def _log_exception(self):
# kwargs doesn't match a variable in the message
# log the issue and the kwargs
LOG.exception('Exception in string format operation')
for name, value in self.kwargs.items():
LOG.error("%s: %s" % (name, value)) # noqa
def format_message(self):
# Use the first argument to the python Exception object which
# should be our full exception message, (see __init__).
return self.args[0]
class NotFound(_BaseException):
msg_fmt = _("Resource could not be found.")
class Exists(_BaseException):
msg_fmt = _("Resource already exists.")
class InvalidInventory(_BaseException):
msg_fmt = _("Inventory for '%(resource_class)s' on "
"resource provider '%(resource_provider)s' invalid.")
class CannotDeleteParentResourceProvider(_BaseException):
msg_fmt = _("Cannot delete resource provider that is a parent of "
"another. Delete child providers first.")
class ConcurrentUpdateDetected(_BaseException):
msg_fmt = _("Another thread concurrently updated the data. "
"Please retry your update")
class ResourceProviderConcurrentUpdateDetected(ConcurrentUpdateDetected):
msg_fmt = _("Another thread concurrently updated the resource provider "
"data. Please retry your update")
class InvalidAllocationCapacityExceeded(InvalidInventory):
msg_fmt = _("Unable to create allocation for '%(resource_class)s' on "
"resource provider '%(resource_provider)s'. The requested "
"amount would exceed the capacity.")
class InvalidAllocationConstraintsViolated(InvalidInventory):
msg_fmt = _("Unable to create allocation for '%(resource_class)s' on "
"resource provider '%(resource_provider)s'. The requested "
"amount would violate inventory constraints.")
class InvalidInventoryCapacity(InvalidInventory):
msg_fmt = _("Invalid inventory for '%(resource_class)s' on "
"resource provider '%(resource_provider)s'. "
"The reserved value is greater than or equal to total.")
class InvalidInventoryCapacityReservedCanBeTotal(InvalidInventoryCapacity):
msg_fmt = _("Invalid inventory for '%(resource_class)s' on "
"resource provider '%(resource_provider)s'. "
"The reserved value is greater than total.")
# An exception with this name is used on both sides of the placement/
# nova interaction.
class InventoryInUse(InvalidInventory):
# NOTE(mriedem): This message cannot change without impacting the
# nova.scheduler.client.report._RE_INV_IN_USE regex.
msg_fmt = _("Inventory for '%(resource_classes)s' on "
"resource provider '%(resource_provider)s' in use.")
class InventoryWithResourceClassNotFound(NotFound):
msg_fmt = _("No inventory of class %(resource_class)s found.")
class MaxDBRetriesExceeded(_BaseException):
msg_fmt = _("Max retries of DB transaction exceeded attempting to "
"perform %(action)s.")
class ObjectActionError(_BaseException):
msg_fmt = _('Object action %(action)s failed because: %(reason)s')
class PolicyNotAuthorized(_BaseException):
msg_fmt = _("Policy does not allow %(action)s to be performed.")
class ResourceClassCannotDeleteStandard(_BaseException):
msg_fmt = _("Cannot delete standard resource class %(resource_class)s.")
class ResourceClassCannotUpdateStandard(_BaseException):
msg_fmt = _("Cannot update standard resource class %(resource_class)s.")
class ResourceClassExists(_BaseException):
msg_fmt = _("Resource class %(resource_class)s already exists.")
class ResourceClassInUse(_BaseException):
msg_fmt = _("Cannot delete resource class %(resource_class)s. "
"Class is in use in inventory.")
class ResourceClassNotFound(NotFound):
msg_fmt = _("No such resource class %(resource_class)s.")
# An exception with this name is used on both sides of the placement/
# nova interaction.
class ResourceProviderInUse(_BaseException):
msg_fmt = _("Resource provider has allocations.")
class TraitCannotDeleteStandard(_BaseException):
msg_fmt = _("Cannot delete standard trait %(name)s.")
class TraitExists(_BaseException):
msg_fmt = _("The Trait %(name)s already exists")
class TraitInUse(_BaseException):
msg_fmt = _("The trait %(name)s is in use by a resource provider.")
class TraitNotFound(NotFound):
msg_fmt = _("No such trait(s): %(names)s.")
class ProjectNotFound(NotFound):
msg_fmt = _("No such project(s): %(external_id)s.")
class ProjectExists(Exists):
msg_fmt = _("The project %(external_id)s already exists.")
class UserNotFound(NotFound):
msg_fmt = _("No such user(s): %(external_id)s.")
class UserExists(Exists):
msg_fmt = _("The user %(external_id)s already exists.")
class ConsumerNotFound(NotFound):
msg_fmt = _("No such consumer(s): %(uuid)s.")
class ConsumerExists(Exists):
msg_fmt = _("The consumer %(uuid)s already exists.")

@ -1,48 +0,0 @@
# 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.
"""Simple middleware for safely catching unexpected exceptions."""
# NOTE(cdent): This is a super simplified replacement for the nova
# FaultWrapper, which does more than placement needs.
from oslo_log import log as logging
import six
from webob import exc
from nova.api.openstack.placement import util
LOG = logging.getLogger(__name__)
class FaultWrapper(object):
"""Turn an uncaught exception into a status 500.
Uncaught exceptions usually shouldn't happen, if it does it
means there is a bug in the placement service, which should be
fixed.
"""
def __init__(self, application):
self.application = application
def __call__(self, environ, start_response):
try:
return self.application(environ, start_response)
except Exception as unexpected_exception:
LOG.exception('Placement API unexpected error: %s',
unexpected_exception)
formatted_exception = exc.HTTPInternalServerError(
six.text_type(unexpected_exception))
formatted_exception.json_formatter = util.json_error_formatter
return formatted_exception.generate_response(
environ, start_response)

@ -1,231 +0,0 @@
# 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.
"""Handlers for placement API.
Individual handlers are associated with URL paths in the
ROUTE_DECLARATIONS dictionary. At the top level each key is a Routes
compliant path. The value of that key is a dictionary mapping
individual HTTP request methods to a Python function representing a
simple WSGI application for satisfying that request.
The ``make_map`` method processes ROUTE_DECLARATIONS to create a
Routes.Mapper, including automatic handlers to respond with a
405 when a request is made against a valid URL with an invalid
method.
"""
import routes
import webob
from oslo_log import log as logging
from nova.api.openstack.placement import exception
from nova.api.openstack.placement.handlers import aggregate
from nova.api.openstack.placement.handlers import allocation
from nova.api.openstack.placement.handlers import allocation_candidate
from nova.api.openstack.placement.handlers import inventory
from nova.api.openstack.placement.handlers import reshaper
from nova.api.openstack.placement.handlers import resource_class
from nova.api.openstack.placement.handlers import resource_provider
from nova.api.openstack.placement.handlers import root
from nova.api.openstack.placement.handlers import trait
from nova.api.openstack.placement.handlers import usage
from nova.api.openstack.placement import util
from nova.i18n import _
LOG = logging.getLogger(__name__)
# URLs and Handlers
# NOTE(cdent): When adding URLs here, do not use regex patterns in
# the path parameters (e.g. {uuid:[0-9a-zA-Z-]+}) as that will lead
# to 404s that are controlled outside of the individual resources
# and thus do not include specific information on the why of the 404.
ROUTE_DECLARATIONS = {
'/': {
'GET': root.home,
},
# NOTE(cdent): This allows '/placement/' and '/placement' to
# both work as the root of the service, which we probably want
# for those situations where the service is mounted under a
# prefix (as it is in devstack). While weird, an empty string is
# a legit key in a dictionary and matches as desired in Routes.
'': {
'GET': root.home,
},
'/resource_classes': {
'GET': resource_class.list_resource_classes,
'POST': resource_class.create_resource_class
},
'/resource_classes/{name}': {
'GET': resource_class.get_resource_class,
'PUT': resource_class.update_resource_class,
'DELETE': resource_class.delete_resource_class,
},
'/resource_providers': {
'GET': resource_provider.list_resource_providers,
'POST': resource_provider.create_resource_provider
},
'/resource_providers/{uuid}': {
'GET': resource_provider.get_resource_provider,
'DELETE': resource_provider.delete_resource_provider,
'PUT': resource_provider.update_resource_provider
},
'/resource_providers/{uuid}/inventories': {
'GET': inventory.get_inventories,
'POST': inventory.create_inventory,
'PUT': inventory.set_inventories,
'DELETE': inventory.delete_inventories
},
'/resource_providers/{uuid}/inventories/{resource_class}': {
'GET': inventory.get_inventory,
'PUT': inventory.update_inventory,
'DELETE': inventory.delete_inventory
},
'/resource_providers/{uuid}/usages': {
'GET': usage.list_usages
},
'/resource_providers/{uuid}/aggregates': {
'GET': aggregate.get_aggregates,
'PUT': aggregate.set_aggregates
},
'/resource_providers/{uuid}/allocations': {
'GET': allocation.list_for_resource_provider,
},
'/allocations': {
'POST': allocation.set_allocations,
},
'/allocations/{consumer_uuid}': {
'GET': allocation.list_for_consumer,
'PUT': allocation.set_allocations_for_consumer,
'DELETE': allocation.delete_allocations,
},
'/allocation_candidates': {
'GET': allocation_candidate.list_allocation_candidates,
},
'/traits': {
'GET': trait.list_traits,
},
'/traits/{name}': {
'GET': trait.get_trait,
'PUT': trait.put_trait,
'DELETE': trait.delete_trait,
},
'/resource_providers/{uuid}/traits': {
'GET': trait.list_traits_for_resource_provider,
'PUT': trait.update_traits_for_resource_provider,
'DELETE': trait.delete_traits_for_resource_provider
},
'/usages': {
'GET': usage.get_total_usages,
},
'/reshaper': {
'POST': reshaper.reshape,
},
}
def dispatch(environ, start_response, mapper):
"""Find a matching route for the current request.
If no match is found, raise a 404 response.
If there is a matching route, but no matching handler
for the given method, raise a 405.
"""
result = mapper.match(environ=environ)
if result is None:
raise webob.exc.HTTPNotFound(
json_formatter=util.json_error_formatter)
# We can't reach this code without action being present.
handler = result.pop('action')
environ['wsgiorg.routing_args'] = ((), result)
return handler(environ, start_response)
def handle_405(environ, start_response):
"""Return a 405 response when method is not allowed.
If _methods are in routing_args, send an allow header listing
the methods that are possible on the provided URL.
"""
_methods = util.wsgi_path_item(environ, '_methods')
headers = {}
if _methods:
# Ensure allow header is a python 2 or 3 native string (thus
# not unicode in python 2 but stay a string in python 3)
# In the process done by Routes to save the allowed methods
# to its routing table they become unicode in py2.
headers['allow'] = str(_methods)
# Use Exception class as WSGI Application. We don't want to raise here.
response = webob.exc.HTTPMethodNotAllowed(
_('The method specified is not allowed for this resource.'),
headers=headers, json_formatter=util.json_error_formatter)
return response(environ, start_response)
def make_map(declarations):
"""Process route declarations to create a Route Mapper."""
mapper = routes.Mapper()
for route, targets in declarations.items():
allowed_methods = []
for method in targets:
mapper.connect(route, action=targets[method],
conditions=dict(method=[method]))
allowed_methods.append(method)
allowed_methods = ', '.join(allowed_methods)
mapper.connect(route, action=handle_405, _methods=allowed_methods)
return mapper
class PlacementHandler(object):
"""Serve Placement API.
Dispatch to handlers defined in ROUTE_DECLARATIONS.
"""
def __init__(self, **local_config):
# NOTE(cdent): Local config currently unused.
self._map = make_map(ROUTE_DECLARATIONS)
def __call__(self, environ, start_response):
# Check that an incoming request with a content-length header
# that is an integer > 0 and not empty, also has a content-type
# header that is not empty. If not raise a 400.
clen = environ.get('CONTENT_LENGTH')
try:
if clen and (int(clen) > 0) and not environ.get('CONTENT_TYPE'):
raise webob.exc.HTTPBadRequest(
_('content-type header required when content-length > 0'),
json_formatter=util.json_error_formatter)
except ValueError as exc:
raise webob.exc.HTTPBadRequest(
_('content-length header must be an integer'),
json_formatter=util.json_error_formatter)
try:
return dispatch(environ, start_response, self._map)
# Trap the NotFound exceptions raised by the objects used
# with the API and transform them into webob.exc.HTTPNotFound.
except exception.NotFound as exc:
raise webob.exc.HTTPNotFound(
exc, json_formatter=util.json_error_formatter)
except exception.PolicyNotAuthorized as exc:
raise webob.exc.HTTPForbidden(
exc.format_message(),
json_formatter=util.json_error_formatter)
# Remaining uncaught exceptions will rise first to the Microversion
# middleware, where any WebOb generated exceptions will be caught and
# transformed into legit HTTP error responses (with microversion
# headers added), and then to the FaultWrapper middleware which will
# catch anything else and transform them into 500 responses.
# NOTE(cdent): There should be very few uncaught exceptions which are
# not WebOb exceptions at this stage as the handlers are contained by
# the wsgify decorator which will transform those exceptions to
# responses itself.

@ -1,133 +0,0 @@
# 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.
"""Aggregate handlers for Placement API."""
from oslo_db import exception as db_exc
from oslo_serialization import jsonutils
from oslo_utils import encodeutils
from oslo_utils import timeutils
import webob
from nova.api.openstack.placement import errors
from nova.api.openstack.placement import exception
from nova.api.openstack.placement import microversion
from nova.api.openstack.placement.objects import resource_provider as rp_obj
from nova.api.openstack.placement.policies import aggregate as policies
from nova.api.openstack.placement.schemas import aggregate as schema
from nova.api.openstack.placement import util
from nova.api.openstack.placement import wsgi_wrapper
from nova.i18n import _
_INCLUDE_GENERATION_VERSION = (1, 19)
def _send_aggregates(req, resource_provider, aggregate_uuids):
want_version = req.environ[microversion.MICROVERSION_ENVIRON]
response = req.response
response.status = 200
payload = _serialize_aggregates(aggregate_uuids)
if want_version.matches(min_version=_INCLUDE_GENERATION_VERSION):
payload['resource_provider_generation'] = resource_provider.generation
response.body = encodeutils.to_utf8(
jsonutils.dumps(payload))
response.content_type = 'application/json'
if want_version.matches((1, 15)):
req.response.cache_control = 'no-cache'
# We never get an aggregate itself, we get the list of aggregates
# that are associated with a resource provider. We don't record the
# time when that association was made and the time when an aggregate
# uuid was created is not relevant, so here we punt and use utcnow.
req.response.last_modified = timeutils.utcnow(with_timezone=True)
return response
def _serialize_aggregates(aggregate_uuids):
return {'aggregates': aggregate_uuids}
def _set_aggregates(resource_provider, aggregate_uuids,
increment_generation=False):
"""Set aggregates for the resource provider.
If increment generation is true, the resource provider generation
will be incremented if possible. If that fails (because something
else incremented the generation in another thread), a
ConcurrentUpdateDetected will be raised.
"""
# NOTE(cdent): It's not clear what the DBDuplicateEntry handling
# is doing here, set_aggregates already handles that, but I'm leaving
# it here because it was already there.
try:
resource_provider.set_aggregates(
aggregate_uuids, increment_generation=increment_generation)
except exception.ConcurrentUpdateDetected as exc:
raise webob.exc.HTTPConflict(
_('Update conflict: %(error)s') % {'error': exc},
comment=errors.CONCURRENT_UPDATE)
except db_exc.DBDuplicateEntry as exc:
raise webob.exc.HTTPConflict(
_('Update conflict: %(error)s') % {'error': exc})
@wsgi_wrapper.PlacementWsgify
@util.check_accept('application/json')
@microversion.version_handler('1.1')
def get_aggregates(req):
"""GET a list of aggregates associated with a resource provider.
If the resource provider does not exist return a 404.
On success return a 200 with an application/json body containing a
list of aggregate uuids.
"""
context = req.environ['placement.context']
context.can(policies.LIST)
uuid = util.wsgi_path_item(req.environ, 'uuid')
resource_provider = rp_obj.ResourceProvider.get_by_uuid(
context, uuid)
aggregate_uuids = resource_provider.get_aggregates()
return _send_aggregates(req, resource_provider, aggregate_uuids)
@wsgi_wrapper.PlacementWsgify
@util.require_content('application/json')
@microversion.version_handler('1.1')
def set_aggregates(req):
context = req.environ['placement.context']
context.can(policies.UPDATE)
want_version = req.environ[microversion.MICROVERSION_ENVIRON]
consider_generation = want_version.matches(
min_version=_INCLUDE_GENERATION_VERSION)
put_schema = schema.PUT_AGGREGATES_SCHEMA_V1_1
if consider_generation:
put_schema = schema.PUT_AGGREGATES_SCHEMA_V1_19
uuid = util.wsgi_path_item(req.environ, 'uuid')
resource_provider = rp_obj.ResourceProvider.get_by_uuid(
context, uuid)
data = util.extract_json(req.body, put_schema)
if consider_generation:
# Check for generation conflict
rp_gen = data['resource_provider_generation']
if resource_provider.generation != rp_gen:
raise webob.exc.HTTPConflict(
_("Resource provider's generation already changed. Please "
"update the generation and try again."),
comment=errors.CONCURRENT_UPDATE)
aggregate_uuids = data['aggregates']
else:
aggregate_uuids = data
_set_aggregates(resource_provider, aggregate_uuids,
increment_generation=consider_generation)
return _send_aggregates(req, resource_provider, aggregate_uuids)

@ -1,576 +0,0 @@
# 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.
"""Placement API handlers for setting and deleting allocations."""
import collections
import uuid
from oslo_log import log as logging
from oslo_serialization import jsonutils
from oslo_utils import encodeutils
from oslo_utils import excutils
from oslo_utils import timeutils
from oslo_utils import uuidutils
import webob
from nova.api.openstack.placement import errors
from nova.api.openstack.placement import exception
from nova.api.openstack.placement import microversion
from nova.api.openstack.placement.objects import resource_provider as rp_obj
from nova.api.openstack.placement.policies import allocation as policies
from nova.api.openstack.placement.schemas import allocation as schema
from nova.api.openstack.placement import util
from nova.api.openstack.placement import wsgi_wrapper
from nova.i18n import _
LOG = logging.getLogger(__name__)
def _last_modified_from_allocations(allocations, want_version):
"""Given a set of allocation objects, returns the last modified timestamp.
"""
# NOTE(cdent): The last_modified for an allocation will always be
# based off the created_at column because allocations are only
# ever inserted, never updated.
last_modified = None
# Only calculate last-modified if we are using a microversion that
# supports it.
get_last_modified = want_version and want_version.matches((1, 15))
for allocation in allocations:
if get_last_modified:
last_modified = util.pick_last_modified(last_modified, allocation)
last_modified = last_modified or timeutils.utcnow(with_timezone=True)
return last_modified
def _serialize_allocations_for_consumer(allocations, want_version):
"""Turn a list of allocations into a dict by resource provider uuid.
{
'allocations': {
RP_UUID_1: {
'generation': GENERATION,
'resources': {
'DISK_GB': 4,
'VCPU': 2
}
},
RP_UUID_2: {
'generation': GENERATION,
'resources': {
'DISK_GB': 6,
'VCPU': 3
}
}
},
# project_id and user_id are added with microverion 1.12
'project_id': PROJECT_ID,
'user_id': USER_ID,
# Generation for consumer >= 1.28
'consumer_generation': 1
}
"""
allocation_data = collections.defaultdict(dict)
for allocation in allocations:
key = allocation.resource_provider.uuid
if 'resources' not in allocation_data[key]:
allocation_data[key]['resources'] = {}
resource_class = allocation.resource_class
allocation_data[key]['resources'][resource_class] = allocation.used
generation = allocation.resource_provider.generation
allocation_data[key]['generation'] = generation
result = {'allocations': allocation_data}
if allocations and want_version.matches((1, 12)):
# We're looking at a list of allocations by consumer id so project and
# user are consistent across the list
consumer = allocations[0].consumer
project_id = consumer.project.external_id
user_id = consumer.user.external_id
result['project_id'] = project_id
result['user_id'] = user_id
show_consumer_gen = want_version.matches((1, 28))
if show_consumer_gen:
result['consumer_generation'] = consumer.generation
return result
def _serialize_allocations_for_resource_provider(allocations,
resource_provider,
want_version):
"""Turn a list of allocations into a dict by consumer id.
{'resource_provider_generation': GENERATION,
'allocations':
CONSUMER_ID_1: {
'resources': {
'DISK_GB': 4,
'VCPU': 2
},
# Generation for consumer >= 1.28
'consumer_generation': 0
},
CONSUMER_ID_2: {
'resources': {
'DISK_GB': 6,
'VCPU': 3
},
# Generation for consumer >= 1.28
'consumer_generation': 0
}
}
"""
show_consumer_gen = want_version.matches((1, 28))
allocation_data = collections.defaultdict(dict)
for allocation in allocations:
key = allocation.consumer.uuid
if 'resources' not in allocation_data[key]:
allocation_data[key]['resources'] = {}
resource_class = allocation.resource_class
allocation_data[key]['resources'][resource_class] = allocation.used
if show_consumer_gen:
consumer_gen = None
if allocation.consumer is not None:
consumer_gen = allocation.consumer.generation
allocation_data[key]['consumer_generation'] = consumer_gen
result = {'allocations': allocation_data}
result['resource_provider_generation'] = resource_provider.generation
return result
# TODO(cdent): Extracting this is useful, for reuse by reshaper code,
# but having it in this file seems wrong, however, since it uses
# _new_allocations it's being left here for now. We need a place for shared
# handler code, but util.py is already too big and too diverse.
def create_allocation_list(context, data, consumers):
"""Create an AllocationList based on provided data.
:param context: The placement context.
:param data: A dictionary of multiple allocations by consumer uuid.
:param consumers: A dictionary, keyed by consumer UUID, of Consumer objects
:return: An AllocationList.
:raises: `webob.exc.HTTPBadRequest` if a resource provider included in the
allocations does not exist.
"""
allocation_objects = []
for consumer_uuid in data:
allocations = data[consumer_uuid]['allocations']
consumer = consumers[consumer_uuid]
if allocations:
rp_objs = _resource_providers_by_uuid(context, allocations.keys())
for resource_provider_uuid in allocations:
resource_provider = rp_objs[resource_provider_uuid]
resources = allocations[resource_provider_uuid]['resources']
new_allocations = _new_allocations(context,
resource_provider,
consumer,
resources)
allocation_objects.extend(new_allocations)
else:
# The allocations are empty, which means wipe them out.
# Internal to the allocation object this is signalled by a
# used value of 0.
allocations = rp_obj.AllocationList.get_all_by_consumer_id(
context, consumer_uuid)
for allocation in allocations:
allocation.used = 0
allocation_objects.append(allocation)
return rp_obj.AllocationList(context, objects=allocation_objects)
def inspect_consumers(context, data, want_version):
"""Look at consumer data in allocations and create consumers as needed.
Keep a record of the consumers that are created in case they need
to be removed later.
If an exception is raised by ensure_consumer, commonly HTTPConflict but
also anything else, the newly created consumers will be deleted and the
exception reraised to the caller.
:param context: The placement context.
:param data: A dictionary of multiple allocations by consumer uuid.
:param want_version: the microversion matcher.
:return: A tuple of a dict of all consumer objects (by consumer uuid)
and a list of those consumer objects which are new.
"""
# First, ensure that all consumers referenced in the payload actually
# exist. And if not, create them. Keep a record of auto-created consumers
# so we can clean them up if the end allocation replace_all() fails.
consumers = {} # dict of Consumer objects, keyed by consumer UUID
new_consumers_created = []
for consumer_uuid in data:
project_id = data[consumer_uuid]['project_id']
user_id = data[consumer_uuid]['user_id']
consumer_generation = data[consumer_uuid].get('consumer_generation')
try:
consumer, new_consumer_created = util.ensure_consumer(
context, consumer_uuid, project_id, user_id,
consumer_generation, want_version)
if new_consumer_created:
new_consumers_created.append(consumer)
consumers[consumer_uuid] = consumer
except Exception:
# If any errors (for instance, a consumer generation conflict)
# occur when ensuring consumer records above, make sure we delete
# any auto-created consumers.
with excutils.save_and_reraise_exception():
delete_consumers(new_consumers_created)
return consumers, new_consumers_created
@wsgi_wrapper.PlacementWsgify
@util.check_accept('application/json')
def list_for_consumer(req):
"""List allocations associated with a consumer."""
context = req.environ['placement.context']
context.can(policies.ALLOC_LIST)
consumer_id = util.wsgi_path_item(req.environ, 'consumer_uuid')
want_version = req.environ[microversion.MICROVERSION_ENVIRON]
# NOTE(cdent): There is no way for a 404 to be returned here,
# only an empty result. We do not have a way to validate a
# consumer id.
allocations = rp_obj.AllocationList.get_all_by_consumer_id(
context, consumer_id)
output = _serialize_allocations_for_consumer(allocations, want_version)
last_modified = _last_modified_from_allocations(allocations, want_version)
allocations_json = jsonutils.dumps(output)
response = req.response
response.status = 200
response.body = encodeutils.to_utf8(allocations_json)
response.content_type = 'application/json'
if want_version.matches((1, 15)):
response.last_modified = last_modified
response.cache_control = 'no-cache'
return response
@wsgi_wrapper.PlacementWsgify
@util.check_accept('application/json')
def list_for_resource_provider(req):
"""List allocations associated with a resource provider."""
# TODO(cdent): On a shared resource provider (for example a
# giant disk farm) this list could get very long. At the moment
# we have no facility for limiting the output. Given that we are
# using a dict of dicts for the output we are potentially limiting
# ourselves in terms of sorting and filtering.
context = req.environ['placement.context']
context.can(policies.RP_ALLOC_LIST)
want_version = req.environ[microversion.MICROVERSION_ENVIRON]
uuid = util.wsgi_path_item(req.environ, 'uuid')
# confirm existence of resource provider so we get a reasonable
# 404 instead of empty list
try:
rp = rp_obj.ResourceProvider.get_by_uuid(context, uuid)
except exception.NotFound as exc:
raise webob.exc.HTTPNotFound(
_("Resource provider '%(rp_uuid)s' not found: %(error)s") %
{'rp_uuid': uuid, 'error': exc})
allocs = rp_obj.AllocationList.get_all_by_resource_provider(context, rp)
output = _serialize_allocations_for_resource_provider(
allocs, rp, want_version)
last_modified = _last_modified_from_allocations(allocs, want_version)
allocations_json = jsonutils.dumps(output)
response = req.response
response.status = 200
response.body = encodeutils.to_utf8(allocations_json)
response.content_type = 'application/json'
if want_version.matches((1, 15)):
response.last_modified = last_modified
response.cache_control = 'no-cache'
return response
def _resource_providers_by_uuid(ctx, rp_uuids):
"""Helper method that returns a dict, keyed by resource provider UUID, of
ResourceProvider objects.
:param ctx: The placement context.
:param rp_uuids: iterable of UUIDs for providers to fetch.
:raises: `webob.exc.HTTPBadRequest` if any of the UUIDs do not refer to
an existing resource provider.
"""
res = {}
for rp_uuid in rp_uuids:
# TODO(jaypipes): Clearly, this is not efficient to do one query for
# each resource provider UUID in the allocations instead of doing a
# single query for all the UUIDs. However, since
# ResourceProviderList.get_all_by_filters() is way too complicated for
# this purpose and doesn't raise NotFound anyway, we'll do this.
# Perhaps consider adding a ResourceProviderList.get_all_by_uuids()
# later on?
try:
res[rp_uuid] = rp_obj.ResourceProvider.get_by_uuid(ctx, rp_uuid)
except exception.NotFound:
raise webob.exc.HTTPBadRequest(
_("Allocation for resource provider '%(rp_uuid)s' "
"that does not exist.") %
{'rp_uuid': rp_uuid})
return res