Use Placement 1.35 (root_required)

Placement microversion 1.35 gives us the root_required queryparam to GET
/allocation_candidates, allowing us to filter out candidates where the
*root* provider has/lacks certain traits, independent of traits
specified in any of the individual request groups.

Use it.

And add affordance for specifying such traits to the RequestSpec.

Which allows us to fix up the couple of request filters that were
hacking traits into the RequestSpec.flavor.

Change-Id: I44f02044ce178e84c23d178e5a23a3aa1208e502
This commit is contained in:
Eric Fried
2019-12-13 17:27:27 -06:00
parent 9428a1e53b
commit bcc893a2b0
12 changed files with 170 additions and 49 deletions

View File

@@ -141,7 +141,7 @@ Upgrade
* The ``Request Spec Migration`` check was removed.
**21.0.0 (Ussuri)**
* Checks for the Placement API are modified to require version 1.34.
* Checks for the Placement API are modified to require version 1.35.
See Also
========

View File

@@ -46,11 +46,12 @@ from nova.volume import cinder
CONF = nova.conf.CONF
# NOTE(efried): 1.34 is required by nova-scheduler to return mappings from
# request group suffixes to the resource providers that satisfy them.
# NOTE(efried): 1.35 is required by nova-scheduler to support the root_required
# queryparam to make GET /allocation_candidates require that a trait be present
# on the root provider, irrespective of how the request groups are specified.
# NOTE: If you bump this version, remember to update the history
# section in the nova-status man page (doc/source/cli/nova-status).
MIN_PLACEMENT_MICROVERSION = "1.34"
MIN_PLACEMENT_MICROVERSION = "1.35"
# NOTE(mriedem): 3.44 is needed to work with volume attachment records which
# are required for supporting multi-attach capable volumes.

View File

@@ -34,7 +34,8 @@ LOG = logging.getLogger(__name__)
REQUEST_SPEC_OPTIONAL_ATTRS = ['requested_destination',
'security_groups',
'network_metadata',
'requested_resources']
'requested_resources',
'request_level_params']
@base.NovaObjectRegistry.register
@@ -52,7 +53,8 @@ class RequestSpec(base.NovaObject):
# Version 1.10: Added network_metadata
# Version 1.11: Added is_bfv
# Version 1.12: Added requested_resources
VERSION = '1.12'
# Version 1.13: Added request_level_params
VERSION = '1.13'
fields = {
'id': fields.IntegerField(),
@@ -103,12 +105,16 @@ class RequestSpec(base.NovaObject):
# NOTE(alex_xu): This field won't be persisted.
'requested_resources': fields.ListOfObjectsField('RequestGroup',
nullable=True,
default=None)
default=None),
# NOTE(efried): This field won't be persisted.
'request_level_params': fields.ObjectField('RequestLevelParams'),
}
def obj_make_compatible(self, primitive, target_version):
super(RequestSpec, self).obj_make_compatible(primitive, target_version)
target_version = versionutils.convert_version_to_tuple(target_version)
if target_version < (1, 13) and 'request_level_params' in primitive:
del primitive['request_level_params']
if target_version < (1, 12):
if 'requested_resources' in primitive:
del primitive['requested_resources']
@@ -142,6 +148,10 @@ class RequestSpec(base.NovaObject):
physnets=set(), tunneled=False)
return
if attrname == 'request_level_params':
self.request_level_params = RequestLevelParams()
return
# NOTE(sbauza): In case the primitive was not providing that field
# because of a previous RequestSpec version, we want to default
# that field in order to have the same behaviour.
@@ -167,6 +177,18 @@ class RequestSpec(base.NovaObject):
def swap(self):
return self.flavor.swap
@property
def root_required(self):
# self.request_level_params and .root_required lazy-default via their
# respective obj_load_attr methods.
return self.request_level_params.root_required
@property
def root_forbidden(self):
# self.request_level_params and .root_forbidden lazy-default via their
# respective obj_load_attr methods.
return self.request_level_params.root_forbidden
def _image_meta_from_image(self, image):
if isinstance(image, objects.ImageMeta):
self.image = image
@@ -497,6 +519,10 @@ class RequestSpec(base.NovaObject):
if port_resource_requests:
spec_obj.requested_resources.extend(port_resource_requests)
# NOTE(efried): We don't need to handle request_level_params here yet
# because they're set dynamically by the scheduler. That could change
# in the future.
# NOTE(sbauza): Default the other fields that are not part of the
# original contract
spec_obj.obj_set_defaults()
@@ -537,11 +563,10 @@ class RequestSpec(base.NovaObject):
if key in ['id', 'instance_uuid']:
setattr(spec, key, db_spec[key])
elif key in ('requested_destination', 'requested_resources',
'network_metadata'):
'network_metadata', 'request_level_params'):
# Do not override what we already have in the object as this
# field is not persisted. If save() is called after
# requested_resources, requested_destination or
# network_metadata is populated, it will reset the field to
# one of these fields is populated, it will reset the field to
# None and we'll lose what is set (but not persisted) on the
# object.
continue
@@ -618,10 +643,10 @@ class RequestSpec(base.NovaObject):
if 'instance_group' in spec and spec.instance_group:
spec.instance_group.members = None
spec.instance_group.hosts = None
# NOTE(mriedem): Don't persist retries, requested_destination,
# requested_resources or ignored hosts since those are per-request
# NOTE(mriedem): Don't persist these since they are per-request
for excluded in ('retry', 'requested_destination',
'requested_resources', 'ignore_hosts'):
'requested_resources', 'ignore_hosts',
'request_level_params'):
if excluded in spec and getattr(spec, excluded):
setattr(spec, excluded, None)
# NOTE(stephenfin): Don't persist network metadata since we have
@@ -1182,3 +1207,25 @@ class RequestGroup(base.NovaObject):
for rclass in list(self.resources):
if self.resources[rclass] == 0:
self.resources.pop(rclass)
@base.NovaObjectRegistry.register
class RequestLevelParams(base.NovaObject):
"""Options destined for the "top level" of the placement allocation
candidates query (parallel to, but not including, the list of
RequestGroup).
"""
# Version 1.0: Initial version
VERSION = '1.0'
fields = {
# Traits required on the root provider
'root_required': fields.SetOfStringsField(default=set()),
# Traits forbidden on the root provider
'root_forbidden': fields.SetOfStringsField(default=set()),
# NOTE(efried): group_policy would be appropriate to include here, once
# we have a use case for it.
}
def obj_load_attr(self, attrname):
self.obj_set_defaults(attrname)

View File

@@ -41,7 +41,7 @@ from nova import utils
CONF = nova.conf.CONF
LOG = logging.getLogger(__name__)
WARN_EVERY = 10
MAPPINGS_VERSION = '1.34'
ROOT_REQUIRED_VERSION = '1.35'
RESHAPER_VERSION = '1.30'
CONSUMER_GENERATION_VERSION = '1.28'
ALLOW_RESERVED_EQUAL_TOTAL_INVENTORY_VERSION = '1.26'
@@ -291,7 +291,7 @@ class SchedulerReportClient(object):
"""
# Note that claim_resources() will use this version as well to
# make allocations by `PUT /allocations/{consumer_uuid}`
version = MAPPINGS_VERSION
version = ROOT_REQUIRED_VERSION
qparams = resources.to_querystring()
url = "/allocation_candidates?%s" % qparams
resp = self.get(url, version=version,

View File

@@ -185,11 +185,7 @@ def require_image_type_support(ctxt, request_spec):
'is os-traits up to date?'), trait_name)
return False
# NOTE(danms): We are using the transient flavor in the request spec
# to add the trait that we need. We make sure that we reset the dirty-ness
# of this field to avoid persisting it.
request_spec.flavor.extra_specs['trait:%s' % trait_name] = 'required'
request_spec.obj_reset_changes(fields=['flavor'], recursive=True)
request_spec.root_required.add(trait_name)
LOG.debug('require_image_type_support request filter added required '
'trait %s', trait_name)
@@ -206,13 +202,8 @@ def compute_status_filter(ctxt, request_spec):
service should have the COMPUTE_STATUS_DISABLED trait set and be excluded
by this mandatory pre-filter.
"""
# We're called before scheduler utils resources_from_request_spec builds
# the RequestGroup stuff which gets used to form the
# GET /allocation_candidates call, so mutate the flavor for that call but
# don't persist the change.
trait_name = os_traits.COMPUTE_STATUS_DISABLED
request_spec.flavor.extra_specs['trait:%s' % trait_name] = 'forbidden'
request_spec.obj_reset_changes(fields=['flavor'], recursive=True)
request_spec.root_forbidden.add(trait_name)
LOG.debug('compute_status_filter request filter added forbidden '
'trait %s', trait_name)
return True

View File

@@ -61,7 +61,8 @@ class ResourceRequest(object):
"""Create a new instance of ResourceRequest from a RequestSpec.
Examines the flavor, flavor extra specs, (optional) image metadata,
and (optional) requested_resources of the provided ``request_spec``.
and (optional) requested_resources and request_level_params of the
provided ``request_spec``.
For extra specs, items of the following form are examined:
@@ -95,6 +96,9 @@ class ResourceRequest(object):
image (e.g. from ports) are incorporated as is, but ensuring that they
get unique group suffixes.
request_level_params - settings associated with the request as a whole
rather than with a specific RequestGroup - are incorporated as is.
:param request_spec: An instance of ``objects.RequestSpec``.
:param enable_pinning_translate: True if the CPU policy extra specs
should be translated to placement resources and traits.
@@ -102,6 +106,10 @@ class ResourceRequest(object):
# { ident: RequestGroup }
self._rg_by_id = {}
self._group_policy = None
# root_required+=these
self._root_required = request_spec.root_required
# root_required+=!these
self._root_forbidden = request_spec.root_forbidden
# Default to the configured limit but _limit can be
# set to None to indicate "no limit".
self._limit = CONF.scheduler.max_placement_results
@@ -446,6 +454,10 @@ class ResourceRequest(object):
qparams = []
if self._group_policy is not None:
qparams.append(('group_policy', self._group_policy))
if self._root_required or self._root_forbidden:
vals = sorted(self._root_required) + ['!' + t for t in
sorted(self._root_forbidden)]
qparams.append(('root_required', ','.join(vals)))
for ident, rg in self._rg_by_id.items():
# [('resources[$S]', 'rclass:amount,rclass:amount,...'),

View File

@@ -12,9 +12,11 @@
# under the License.
import copy
import ddt
from keystoneauth1 import exceptions as kse
import mock
import os_resource_classes as orc
import os_traits as ot
from oslo_config import cfg
from oslo_config import fixture as config_fixture
from oslo_utils.fixture import uuidsentinel as uuids
@@ -141,6 +143,7 @@ class SchedulerReportClientTestBase(test.TestCase):
pass
@ddt.ddt
@mock.patch('nova.compute.utils.is_volume_backed_instance',
new=mock.Mock(return_value=False))
@mock.patch('nova.objects.compute_node.ComputeNode.save', new=mock.Mock())
@@ -1299,6 +1302,64 @@ class SchedulerReportClientTests(SchedulerReportClientTestBase):
self.assertEqual({'_CPU', '_MEM', '_DISK'},
set(ac['mappings']))
# One data element is:
# root_required: set of traits for root_required
# root_forbidden: set of traits for root_forbidden
# expected_acs: integer expected number of allocation candidates
@ddt.data(
# With no root_required qparam, we get two candidates involving the
# primary compute and one involving `othercn`. (This is covered
# elsewhere too, but included here as a baseline).
{'root_required': {},
'root_forbidden': {},
'expected_acs': 3},
# Requiring traits that are on our primary compute culls out the result
# involving `othercn`.
{'root_required': {ot.COMPUTE_VOLUME_EXTEND, 'CUSTOM_FOO'},
'root_forbidden': {},
'expected_acs': 2},
# Forbidding a trait that's absent doesn't matter
{'root_required': {ot.COMPUTE_VOLUME_EXTEND, 'CUSTOM_FOO'},
'root_forbidden': {ot.COMPUTE_VOLUME_MULTI_ATTACH},
'expected_acs': 2},
# But forbidding COMPUTE_STATUS_DISABLED kills it
{'root_required': {ot.COMPUTE_VOLUME_EXTEND, 'CUSTOM_FOO'},
'root_forbidden': {ot.COMPUTE_VOLUME_MULTI_ATTACH,
ot.COMPUTE_STATUS_DISABLED},
'expected_acs': 0},
# Removing the required traits brings back the candidate involving
# `othercn`. But the primary compute is still filtered out by the
# root_forbidden.
{'root_required': {},
'root_forbidden': {ot.COMPUTE_VOLUME_MULTI_ATTACH,
ot.COMPUTE_STATUS_DISABLED},
'expected_acs': 1},
)
def test_allocation_candidates_root_traits(self, data):
"""Smoke test to ensure root_{required|forbidden} percolates from the
RequestSpec through to the GET /allocation_candidates response.
We're not trying to prove that root_required works -- Placement tests
for that -- we're just throwing out enough permutations to prove that
we must be building the querystring correctly.
"""
flavor = objects.Flavor(
vcpus=1, memory_mb=1024, root_gb=10, ephemeral_gb=5, swap=0)
req_spec = objects.RequestSpec(flavor=flavor, is_bfv=False)
req_spec.root_required.update(data['root_required'])
req_spec.root_forbidden.update(data['root_forbidden'])
with self._interceptor():
self._set_up_provider_tree()
self.client.set_traits_for_provider(
self.context, self.compute_uuid,
(ot.COMPUTE_STATUS_DISABLED, ot.COMPUTE_VOLUME_EXTEND,
'CUSTOM_FOO'))
acs, _, ver = self.client.get_allocation_candidates(
self.context, utils.ResourceRequest(req_spec))
self.assertEqual('1.35', ver)
# This prints which ddt permutation we're using if it fails.
self.assertEqual(data['expected_acs'], len(acs), data)
def test_get_allocations_for_provider_tree(self):
with self._interceptor():
# When the provider tree cache is empty (or we otherwise supply a

View File

@@ -734,7 +734,7 @@ class MigrationTaskTestCase(test.NoDBTestCase):
allocation_request=jsonutils.dumps(
{"allocations": {uuids.host1: resources},
"mappings": {uuids.port1: [uuids.host1]}}),
allocation_request_version='1.34')
allocation_request_version='1.35')
second = objects.Selection(
service_host="host2",
nodename="node2",
@@ -742,7 +742,7 @@ class MigrationTaskTestCase(test.NoDBTestCase):
allocation_request=jsonutils.dumps(
{"allocations": {uuids.host2: resources},
"mappings": {uuids.port1: [uuids.host2]}}),
allocation_request_version='1.34')
allocation_request_version='1.35')
first_service = objects.Service(service_host='host1')
first_service.version = 38
@@ -765,7 +765,7 @@ class MigrationTaskTestCase(test.NoDBTestCase):
self.context.elevated(), task.reportclient, task.request_spec,
self.instance.uuid,
{"allocations": {uuids.host2: resources},
"mappings": {uuids.port1: [uuids.host2]}}, '1.34')
"mappings": {uuids.port1: [uuids.host2]}}, '1.35')
mock_debug.assert_has_calls([
mock.call(
'Scheduler returned alternate host %(host)s as a possible '

View File

@@ -1120,7 +1120,8 @@ object_data = {
'Quotas': '1.3-40fcefe522111dddd3e5e6155702cf4e',
'QuotasNoOp': '1.3-347a039fc7cfee7b225b68b5181e0733',
'RequestGroup': '1.3-0458d350a8ec9d0673f9be5640a990ce',
'RequestSpec': '1.12-25010470f219af9b6163f2a457a513f5',
'RequestLevelParams': '1.0-1e5c8c18bd44cd233c8b32509c99d06f',
'RequestSpec': '1.13-e1aa38b2bf3f8547474ee9e4c0aa2745',
'Resource': '1.0-d8a2abbb380da583b995fd118f6a8953',
'ResourceList': '1.0-4a53826625cc280e15fae64a575e0879',
'ResourceMetadata': '1.0-77509ea1ea0dd750d5864b9bd87d3f9d',

View File

@@ -307,7 +307,8 @@ class _TestRequestSpecObject(object):
spec = objects.RequestSpec.from_primitives(ctxt, spec_dict, filt_props)
mock_limits.assert_called_once_with({})
# Make sure that all fields are set using that helper method
skip = ['id', 'security_groups', 'network_metadata', 'is_bfv']
skip = ['id', 'security_groups', 'network_metadata', 'is_bfv',
'request_level_params']
for field in [f for f in spec.obj_fields if f not in skip]:
self.assertTrue(spec.obj_attr_is_set(field),
'Field: %s is not set' % field)
@@ -338,7 +339,7 @@ class _TestRequestSpecObject(object):
filter_properties, instance_group, instance.availability_zone,
objects.SecurityGroupList())
# Make sure that all fields are set using that helper method
skip = ['id', 'network_metadata', 'is_bfv']
skip = ['id', 'network_metadata', 'is_bfv', 'request_level_params']
for field in [f for f in spec.obj_fields if f not in skip]:
self.assertTrue(spec.obj_attr_is_set(field),
'Field: %s is not set' % field)

View File

@@ -2125,7 +2125,7 @@ class TestProviderOperations(SchedulerReportClientTestCase):
expected_url = '/allocation_candidates?%s' % parse.urlencode(
expected_query)
self.ks_adap_mock.get.assert_called_once_with(
expected_url, microversion='1.34',
expected_url, microversion='1.35',
global_request_id=self.context.global_id)
self.assertEqual(mock.sentinel.alloc_reqs, alloc_reqs)
self.assertEqual(mock.sentinel.p_sums, p_sums)
@@ -2169,7 +2169,7 @@ class TestProviderOperations(SchedulerReportClientTestCase):
expected_query)
self.assertEqual(mock.sentinel.alloc_reqs, alloc_reqs)
self.ks_adap_mock.get.assert_called_once_with(
expected_url, microversion='1.34',
expected_url, microversion='1.35',
global_request_id=self.context.global_id)
self.assertEqual(mock.sentinel.p_sums, p_sums)
@@ -2195,7 +2195,7 @@ class TestProviderOperations(SchedulerReportClientTestCase):
res = self.client.get_allocation_candidates(self.context, resources)
self.ks_adap_mock.get.assert_called_once_with(
mock.ANY, microversion='1.34',
mock.ANY, microversion='1.35',
global_request_id=self.context.global_id)
url = self.ks_adap_mock.get.call_args[0][0]
split_url = parse.urlsplit(url)

View File

@@ -11,6 +11,7 @@
# under the License.
import mock
import os_traits as ot
from oslo_utils.fixture import uuidsentinel as uuids
from oslo_utils import timeutils
@@ -339,7 +340,8 @@ class TestRequestFilter(test.NoDBTestCase):
is_bfv=False)
# Assert that we completely skip the filter if disabled
request_filter.require_image_type_support(self.context, reqspec)
self.assertEqual({}, reqspec.flavor.extra_specs)
self.assertEqual(0, len(reqspec.root_required))
self.assertEqual(0, len(reqspec.root_forbidden))
def test_require_image_type_support_volume_backed(self):
self.flags(query_placement_for_image_type_support=True,
@@ -348,7 +350,8 @@ class TestRequestFilter(test.NoDBTestCase):
is_bfv=True)
# Assert that we completely skip the filter if no image
request_filter.require_image_type_support(self.context, reqspec)
self.assertEqual({}, reqspec.flavor.extra_specs)
self.assertEqual(0, len(reqspec.root_required))
self.assertEqual(0, len(reqspec.root_forbidden))
def test_require_image_type_support_unknown(self):
self.flags(query_placement_for_image_type_support=True,
@@ -359,7 +362,8 @@ class TestRequestFilter(test.NoDBTestCase):
is_bfv=False)
# Assert that we completely skip the filter if no matching trait
request_filter.require_image_type_support(self.context, reqspec)
self.assertEqual({}, reqspec.flavor.extra_specs)
self.assertEqual(0, len(reqspec.root_required))
self.assertEqual(0, len(reqspec.root_forbidden))
@mock.patch.object(request_filter, 'LOG')
def test_require_image_type_support_adds_trait(self, mock_log):
@@ -369,11 +373,13 @@ class TestRequestFilter(test.NoDBTestCase):
image=objects.ImageMeta(disk_format='raw'),
flavor=objects.Flavor(extra_specs={}),
is_bfv=False)
# Assert that we add the trait to the flavor as required
self.assertEqual(0, len(reqspec.root_required))
self.assertEqual(0, len(reqspec.root_forbidden))
# Request filter puts the trait into the request spec
request_filter.require_image_type_support(self.context, reqspec)
self.assertEqual({'trait:COMPUTE_IMAGE_TYPE_RAW': 'required'},
reqspec.flavor.extra_specs)
self.assertEqual(set(), reqspec.flavor.obj_what_changed())
self.assertEqual({ot.COMPUTE_IMAGE_TYPE_RAW}, reqspec.root_required)
self.assertEqual(0, len(reqspec.root_forbidden))
log_lines = [c[0][0] for c in mock_log.debug.call_args_list]
self.assertIn('added required trait', log_lines[0])
@@ -382,13 +388,14 @@ class TestRequestFilter(test.NoDBTestCase):
@mock.patch.object(request_filter, 'LOG')
def test_compute_status_filter(self, mock_log):
reqspec = objects.RequestSpec(flavor=objects.Flavor(extra_specs={}))
self.assertEqual(0, len(reqspec.root_required))
self.assertEqual(0, len(reqspec.root_forbidden))
# Request filter puts the trait into the request spec
request_filter.compute_status_filter(self.context, reqspec)
# The forbidden trait should be added to the RequestSpec.flavor.
self.assertEqual({'trait:COMPUTE_STATUS_DISABLED': 'forbidden'},
reqspec.flavor.extra_specs)
# The RequestSpec.flavor changes should be reset so they are not
# persisted.
self.assertEqual(set(), reqspec.flavor.obj_what_changed())
self.assertEqual(0, len(reqspec.root_required))
self.assertEqual({ot.COMPUTE_STATUS_DISABLED}, reqspec.root_forbidden)
# Assert both the in-method logging and trace decorator.
log_lines = [c[0][0] for c in mock_log.debug.call_args_list]
self.assertIn('added forbidden trait', log_lines[0])