From 83030804cc7556d50023c2968c51d2a6173063a0 Mon Sep 17 00:00:00 2001 From: Chris Dent Date: Mon, 20 Nov 2017 18:08:06 +0000 Subject: [PATCH] [placement] Add cache headers to placement api requests In relevant requests to the placement API add last-modified and cache-control headers. According the HTTP 1.1 RFC headers last-modified headers SHOULD always be sent and should have a tie to the real last modified time. If we do send them, we need Cache-Control headers to prevent inadvertent caching of resources. This change adds a microversion 1.15 which adds the headers to GET requests and some PUT or POST requests. Despite what it says 'no-cache' means "check to see if the version you have is still valid as far as the server is concerned". Since our server doesn't currently validate conditional requests and will always return an entity, it ends up meaning "don't cache" (which is what we want). The main steps in the patch are: * To both the get single entity and get collection handlers add response.cache_control = 'no-cache' * For single entity add response.last_modified = obj.updated_at or obj.created_at * For collections, discover the max modified time when traversing the list of objects to create the serialized JSON output. In most of those loops an optimization is done where we only check for last-modified information if we have a high enough microversion such that the information will be used. This is not done when listing inventories because the expectation is that no single resource provider will ever have a huge number of inventory records. * Both of the prior steps are assisted by a new util method: pick_last_modfied. Where a time cannot be determined the current time is used. In typical placement framework fashion this has been done in a very explicit way, as it makes what the handler is doing very visible, even though it results in a bit of boilerplate. For those requests that are created from multiple objects or by doing calculations, such as usages and aggregate associations, the current time is used. The handler for PUT /traits is modified a bit more extensively than some of the others: This is because the method can either create or validate the existence of the trait. In the case where the trait already exists, we need to get it from the DB to get its created_at time. We only do this if the microversion is high enough (at least 1.15) to warrant needing the info. Because these changes add new headers (even though they don't do anything) a new microversion, 1.15, is added. Partial-Bug: #1632852 Partially-Implements: bp placement-cache-headers Change-Id: I727d4c77aaa31f0ef31c8af22c2d46cad8ab8b8e --- .../openstack/placement/handlers/aggregate.py | 16 ++- .../placement/handlers/allocation.py | 49 +++++-- .../handlers/allocation_candidate.py | 4 + .../openstack/placement/handlers/inventory.py | 36 +++-- .../placement/handlers/resource_class.py | 28 +++- .../placement/handlers/resource_provider.py | 42 ++++-- nova/api/openstack/placement/handlers/root.py | 5 + .../api/openstack/placement/handlers/trait.py | 50 ++++++- .../api/openstack/placement/handlers/usage.py | 19 +++ nova/api/openstack/placement/microversion.py | 2 +- .../placement/rest_api_version_history.rst | 10 ++ nova/api/openstack/placement/util.py | 20 +++ .../placement/gabbits/aggregate.yaml | 30 ++++ .../gabbits/allocation-candidates.yaml | 15 ++ .../placement/gabbits/allocations.yaml | 39 ++++++ .../placement/gabbits/basic-http.yaml | 17 +++ .../placement/gabbits/inventory.yaml | 29 ++++ .../placement/gabbits/microversion.yaml | 4 +- .../resource-classes-last-modified.yaml | 117 ++++++++++++++++ .../placement/gabbits/resource-provider.yaml | 31 ++++ .../openstack/placement/gabbits/traits.yaml | 132 +++++++++++++++++- .../openstack/placement/gabbits/usage.yaml | 15 ++ .../placement/gabbits/with-allocations.yaml | 13 ++ .../api/openstack/placement/test_handler.py | 4 + .../unit/api/openstack/placement/test_util.py | 84 +++++++++++ ...cement-last-modified-cf43aece4c54fc97.yaml | 10 ++ 26 files changed, 767 insertions(+), 54 deletions(-) create mode 100644 nova/tests/functional/api/openstack/placement/gabbits/resource-classes-last-modified.yaml create mode 100644 releasenotes/notes/placement-last-modified-cf43aece4c54fc97.yaml diff --git a/nova/api/openstack/placement/handlers/aggregate.py b/nova/api/openstack/placement/handlers/aggregate.py index 3fec87ddbed0..382f190477f5 100644 --- a/nova/api/openstack/placement/handlers/aggregate.py +++ b/nova/api/openstack/placement/handlers/aggregate.py @@ -13,6 +13,7 @@ from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils from nova.api.openstack.placement import microversion from nova.api.openstack.placement import util @@ -30,11 +31,20 @@ PUT_AGGREGATES_SCHEMA = { } -def _send_aggregates(response, aggregate_uuids): +def _send_aggregates(req, aggregate_uuids): + want_version = req.environ[microversion.MICROVERSION_ENVIRON] + response = req.response response.status = 200 response.body = encodeutils.to_utf8( jsonutils.dumps(_serialize_aggregates(aggregate_uuids))) 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 @@ -59,7 +69,7 @@ def get_aggregates(req): context, uuid) aggregate_uuids = resource_provider.get_aggregates() - return _send_aggregates(req.response, aggregate_uuids) + return _send_aggregates(req, aggregate_uuids) @wsgi_wrapper.PlacementWsgify @@ -73,4 +83,4 @@ def set_aggregates(req): aggregate_uuids = util.extract_json(req.body, PUT_AGGREGATES_SCHEMA) resource_provider.set_aggregates(aggregate_uuids) - return _send_aggregates(req.response, aggregate_uuids) + return _send_aggregates(req, aggregate_uuids) diff --git a/nova/api/openstack/placement/handlers/allocation.py b/nova/api/openstack/placement/handlers/allocation.py index c06d4db05dba..bafbbdf063cf 100644 --- a/nova/api/openstack/placement/handlers/allocation.py +++ b/nova/api/openstack/placement/handlers/allocation.py @@ -17,6 +17,7 @@ import copy from oslo_log import log as logging from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils import webob from nova.api.openstack.placement import microversion @@ -161,7 +162,16 @@ def _allocations_dict(allocations, key_fetcher, resource_provider=None, """Turn allocations into a dict of resources keyed by key_fetcher.""" allocation_data = collections.defaultdict(dict) + # 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) key = key_fetcher(allocation) if 'resources' not in allocation_data[key]: allocation_data[key]['resources'] = {} @@ -183,7 +193,8 @@ def _allocations_dict(allocations, key_fetcher, resource_provider=None, result['project_id'] = allocations[0].project_id result['user_id'] = allocations[0].user_id - return result + last_modified = last_modified or timeutils.utcnow(with_timezone=True) + return result, last_modified def _serialize_allocations_for_consumer(allocations, want_version=None): @@ -245,6 +256,7 @@ def _serialize_allocations_for_resource_provider(allocations, def list_for_consumer(req): """List allocations associated with a consumer.""" context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] consumer_id = util.wsgi_path_item(req.environ, 'consumer_uuid') want_version = req.environ[microversion.MICROVERSION_ENVIRON] @@ -254,13 +266,18 @@ def list_for_consumer(req): allocations = rp_obj.AllocationList.get_all_by_consumer_id( context, consumer_id) - allocations_json = jsonutils.dumps( - _serialize_allocations_for_consumer(allocations, want_version)) + output, last_modified = _serialize_allocations_for_consumer( + allocations, want_version) + allocations_json = jsonutils.dumps(output) - req.response.status = 200 - req.response.body = encodeutils.to_utf8(allocations_json) - req.response.content_type = 'application/json' - return req.response + 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 @@ -273,6 +290,7 @@ def list_for_resource_provider(req): # using a dict of dicts for the output we are potentially limiting # ourselves in terms of sorting and filtering. context = req.environ['placement.context'] + 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 @@ -286,13 +304,18 @@ def list_for_resource_provider(req): allocs = rp_obj.AllocationList.get_all_by_resource_provider(context, rp) - allocations_json = jsonutils.dumps( - _serialize_allocations_for_resource_provider(allocs, rp)) + output, last_modified = _serialize_allocations_for_resource_provider( + allocs, rp) + allocations_json = jsonutils.dumps(output) - req.response.status = 200 - req.response.body = encodeutils.to_utf8(allocations_json) - req.response.content_type = 'application/json' - return req.response + 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 _new_allocations(context, resource_provider_uuid, consumer_uuid, diff --git a/nova/api/openstack/placement/handlers/allocation_candidate.py b/nova/api/openstack/placement/handlers/allocation_candidate.py index 94b6b79e0bf4..56635f54cd84 100644 --- a/nova/api/openstack/placement/handlers/allocation_candidate.py +++ b/nova/api/openstack/placement/handlers/allocation_candidate.py @@ -17,6 +17,7 @@ import collections from oslo_log import log as logging from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils import webob from nova.api.openstack.placement import microversion @@ -224,4 +225,7 @@ def list_allocation_candidates(req): json_data = jsonutils.dumps(trx_cands) response.body = encodeutils.to_utf8(json_data) response.content_type = 'application/json' + if want_version.matches((1, 15)): + response.cache_control = 'no-cache' + response.last_modified = timeutils.utcnow(with_timezone=True) return response diff --git a/nova/api/openstack/placement/handlers/inventory.py b/nova/api/openstack/placement/handlers/inventory.py index 3d9622053b7a..15d6b3c2097d 100644 --- a/nova/api/openstack/placement/handlers/inventory.py +++ b/nova/api/openstack/placement/handlers/inventory.py @@ -161,21 +161,33 @@ def _make_inventory_object(resource_provider, resource_class, **data): return inventory -def _send_inventories(response, resource_provider, inventories): +def _send_inventories(req, resource_provider, inventories): """Send a JSON representation of a list of inventories.""" + response = req.response response.status = 200 - response.body = encodeutils.to_utf8(jsonutils.dumps( - _serialize_inventories(inventories, resource_provider.generation))) + output, last_modified = _serialize_inventories( + inventories, resource_provider.generation) + response.body = encodeutils.to_utf8(jsonutils.dumps(output)) response.content_type = 'application/json' + want_version = req.environ[microversion.MICROVERSION_ENVIRON] + if want_version.matches((1, 15)): + response.last_modified = last_modified + response.cache_control = 'no-cache' return response -def _send_inventory(response, resource_provider, inventory, status=200): +def _send_inventory(req, resource_provider, inventory, status=200): """Send a JSON representation of one single inventory.""" + response = req.response response.status = status response.body = encodeutils.to_utf8(jsonutils.dumps(_serialize_inventory( inventory, generation=resource_provider.generation))) response.content_type = 'application/json' + want_version = req.environ[microversion.MICROVERSION_ENVIRON] + if want_version.matches((1, 15)): + modified = util.pick_last_modified(None, inventory) + response.last_modified = modified + response.cache_control = 'no-cache' return response @@ -195,11 +207,13 @@ def _serialize_inventories(inventories, generation): inventories_by_class = {inventory.resource_class: inventory for inventory in inventories} inventories_dict = {} + last_modified = None for resource_class, inventory in inventories_by_class.items(): + last_modified = util.pick_last_modified(last_modified, inventory) inventories_dict[resource_class] = _serialize_inventory( inventory, generation=None) - return {'resource_provider_generation': generation, - 'inventories': inventories_dict} + return ({'resource_provider_generation': generation, + 'inventories': inventories_dict}, last_modified) @wsgi_wrapper.PlacementWsgify @@ -238,7 +252,7 @@ def create_inventory(req): response = req.response response.location = util.inventory_url( req.environ, resource_provider, resource_class) - return _send_inventory(response, resource_provider, inventory, + return _send_inventory(req, resource_provider, inventory, status=201) @@ -294,7 +308,7 @@ def get_inventories(req): inv_list = rp_obj.InventoryList.get_all_by_resource_provider(context, rp) - return _send_inventories(req.response, rp, inv_list) + return _send_inventories(req, rp, inv_list) @wsgi_wrapper.PlacementWsgify @@ -323,7 +337,7 @@ def get_inventory(req): _('No inventory of class %(class)s for %(rp_uuid)s') % {'class': resource_class, 'rp_uuid': uuid}) - return _send_inventory(req.response, rp, inventory) + return _send_inventory(req, rp, inventory) @wsgi_wrapper.PlacementWsgify @@ -383,7 +397,7 @@ def set_inventories(req): '%(rp_uuid)s: %(error)s') % {'rp_uuid': resource_provider.uuid, 'error': exc}) - return _send_inventories(req.response, resource_provider, inventories) + return _send_inventories(req, resource_provider, inventories) @wsgi_wrapper.PlacementWsgify @@ -468,4 +482,4 @@ def update_inventory(req): '%(rp_uuid)s: %(error)s') % {'rp_uuid': resource_provider.uuid, 'error': exc}) - return _send_inventory(req.response, resource_provider, inventory) + return _send_inventory(req, resource_provider, inventory) diff --git a/nova/api/openstack/placement/handlers/resource_class.py b/nova/api/openstack/placement/handlers/resource_class.py index 99a0dc0536c8..2cbeb7d91ac4 100644 --- a/nova/api/openstack/placement/handlers/resource_class.py +++ b/nova/api/openstack/placement/handlers/resource_class.py @@ -15,6 +15,7 @@ import copy from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils import webob from nova.api.openstack.placement import microversion @@ -56,12 +57,17 @@ def _serialize_resource_class(environ, rc): return data -def _serialize_resource_classes(environ, rcs): +def _serialize_resource_classes(environ, rcs, want_version): output = [] + last_modified = None + get_last_modified = want_version.matches((1, 15)) for rc in rcs: + if get_last_modified: + last_modified = util.pick_last_modified(last_modified, rc) data = _serialize_resource_class(environ, rc) output.append(data) - return {"resource_classes": output} + last_modified = last_modified or timeutils.utcnow(with_timezone=True) + return ({"resource_classes": output}, last_modified) @wsgi_wrapper.PlacementWsgify @@ -131,6 +137,7 @@ def get_resource_class(req): """ name = util.wsgi_path_item(req.environ, 'name') context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] # The containing application will catch a not found here. rc = rp_obj.ResourceClass.get_by_name(context, name) @@ -138,6 +145,13 @@ def get_resource_class(req): _serialize_resource_class(req.environ, rc)) ) req.response.content_type = 'application/json' + if want_version.matches((1, 15)): + req.response.cache_control = 'no-cache' + # Non-custom resource classes will return None from pick_last_modified, + # so the 'or' causes utcnow to be used. + last_modified = util.pick_last_modified(None, rc) or timeutils.utcnow( + with_timezone=True) + req.response.last_modified = last_modified return req.response @@ -151,13 +165,17 @@ def list_resource_classes(req): a collection of resource classes. """ context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] rcs = rp_obj.ResourceClassList.get_all(context) response = req.response - response.body = encodeutils.to_utf8(jsonutils.dumps( - _serialize_resource_classes(req.environ, rcs)) - ) + output, last_modified = _serialize_resource_classes( + req.environ, rcs, want_version) + response.body = encodeutils.to_utf8(jsonutils.dumps(output)) response.content_type = 'application/json' + if want_version.matches((1, 15)): + response.last_modified = last_modified + response.cache_control = 'no-cache' return response diff --git a/nova/api/openstack/placement/handlers/resource_provider.py b/nova/api/openstack/placement/handlers/resource_provider.py index 81ecce08638a..94eae5ca7ac0 100644 --- a/nova/api/openstack/placement/handlers/resource_provider.py +++ b/nova/api/openstack/placement/handlers/resource_provider.py @@ -16,6 +16,7 @@ import copy from oslo_db import exception as db_exc from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils from oslo_utils import uuidutils import webob @@ -140,10 +141,15 @@ def _serialize_provider(environ, resource_provider, want_version): def _serialize_providers(environ, resource_providers, want_version): output = [] + last_modified = None + get_last_modified = want_version.matches((1, 15)) for provider in resource_providers: + if get_last_modified: + last_modified = util.pick_last_modified(last_modified, provider) provider_data = _serialize_provider(environ, provider, want_version) output.append(provider_data) - return {"resource_providers": output} + last_modified = last_modified or timeutils.utcnow(with_timezone=True) + return ({"resource_providers": output}, last_modified) @wsgi_wrapper.PlacementWsgify @@ -219,6 +225,7 @@ def get_resource_provider(req): On success return a 200 with an application/json body representing the resource provider. """ + want_version = req.environ[microversion.MICROVERSION_ENVIRON] uuid = util.wsgi_path_item(req.environ, 'uuid') # The containing application will catch a not found here. context = req.environ['placement.context'] @@ -226,11 +233,15 @@ def get_resource_provider(req): resource_provider = rp_obj.ResourceProvider.get_by_uuid( context, uuid) - want_version = req.environ[microversion.MICROVERSION_ENVIRON] - req.response.body = encodeutils.to_utf8(jsonutils.dumps( + response = req.response + response.body = encodeutils.to_utf8(jsonutils.dumps( _serialize_provider(req.environ, resource_provider, want_version))) - req.response.content_type = 'application/json' - return req.response + response.content_type = 'application/json' + if want_version.matches((1, 15)): + modified = util.pick_last_modified(None, resource_provider) + response.last_modified = modified + response.cache_control = 'no-cache' + return response @wsgi_wrapper.PlacementWsgify @@ -287,9 +298,13 @@ def list_resource_providers(req): {'error': exc}) response = req.response - response.body = encodeutils.to_utf8(jsonutils.dumps( - _serialize_providers(req.environ, resource_providers, want_version))) + output, last_modified = _serialize_providers( + req.environ, resource_providers, want_version) + response.body = encodeutils.to_utf8(jsonutils.dumps(output)) response.content_type = 'application/json' + if want_version.matches((1, 15)): + response.last_modified = last_modified + response.cache_control = 'no-cache' return response @@ -303,6 +318,7 @@ def update_resource_provider(req): """ uuid = util.wsgi_path_item(req.environ, 'uuid') context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] # The containing application will catch a not found here. resource_provider = rp_obj.ResourceProvider.get_by_uuid( @@ -330,8 +346,12 @@ def update_resource_provider(req): _('Unable to save resource provider %(rp_uuid)s: %(error)s') % {'rp_uuid': uuid, 'error': exc}) - req.response.body = encodeutils.to_utf8(jsonutils.dumps( + response = req.response + response.status = 200 + response.body = encodeutils.to_utf8(jsonutils.dumps( _serialize_provider(req.environ, resource_provider, want_version))) - req.response.status = 200 - req.response.content_type = 'application/json' - return req.response + response.content_type = 'application/json' + if want_version.matches((1, 15)): + response.last_modified = resource_provider.updated_at + response.cache_control = 'no-cache' + return response diff --git a/nova/api/openstack/placement/handlers/root.py b/nova/api/openstack/placement/handlers/root.py index 9b5b1bb948dc..2f9c6861f2bc 100644 --- a/nova/api/openstack/placement/handlers/root.py +++ b/nova/api/openstack/placement/handlers/root.py @@ -13,6 +13,7 @@ from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils from nova.api.openstack.placement import microversion @@ -21,6 +22,7 @@ from nova.api.openstack.placement import wsgi_wrapper @wsgi_wrapper.PlacementWsgify def home(req): + want_version = req.environ[microversion.MICROVERSION_ENVIRON] min_version = microversion.min_version_string() max_version = microversion.max_version_string() # NOTE(cdent): As sections of the api are added, links can be @@ -34,4 +36,7 @@ def home(req): version_json = jsonutils.dumps({'versions': [version_data]}) req.response.body = encodeutils.to_utf8(version_json) req.response.content_type = 'application/json' + if want_version.matches((1, 15)): + req.response.cache_control = 'no-cache' + req.response.last_modified = timeutils.utcnow(with_timezone=True) return req.response diff --git a/nova/api/openstack/placement/handlers/trait.py b/nova/api/openstack/placement/handlers/trait.py index 50fc6b7dafd8..e5c6a38cbadc 100644 --- a/nova/api/openstack/placement/handlers/trait.py +++ b/nova/api/openstack/placement/handlers/trait.py @@ -16,6 +16,7 @@ import copy import jsonschema from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils import webob from nova.api.openstack.placement import microversion @@ -85,14 +86,26 @@ def _normalize_traits_qs_param(qs): return filters -def _serialize_traits(traits): - return {'traits': [trait.name for trait in traits]} +def _serialize_traits(traits, want_version): + last_modified = None + get_last_modified = want_version.matches((1, 15)) + trait_names = [] + for trait in traits: + if get_last_modified: + last_modified = util.pick_last_modified(last_modified, trait) + trait_names.append(trait.name) + + # If there were no traits, set last_modified to now + last_modified = last_modified or timeutils.utcnow(with_timezone=True) + + return {'traits': trait_names}, last_modified @wsgi_wrapper.PlacementWsgify @microversion.version_handler('1.6') def put_trait(req): context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] name = util.wsgi_path_item(req.environ, 'name') try: @@ -110,10 +123,16 @@ def put_trait(req): trait.create() req.response.status = 201 except exception.TraitExists: + # Get the trait that already exists to get last-modified time. + if want_version.matches((1, 15)): + trait = rp_obj.Trait.get_by_name(context, name) req.response.status = 204 req.response.content_type = None req.response.location = util.trait_url(req.environ, trait) + if want_version.matches((1, 15)): + req.response.last_modified = trait.created_at + req.response.cache_control = 'no-cache' return req.response @@ -121,16 +140,20 @@ def put_trait(req): @microversion.version_handler('1.6') def get_trait(req): context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] name = util.wsgi_path_item(req.environ, 'name') try: - rp_obj.Trait.get_by_name(context, name) + trait = rp_obj.Trait.get_by_name(context, name) except exception.TraitNotFound as ex: raise webob.exc.HTTPNotFound( explanation=ex.format_message()) req.response.status = 204 req.response.content_type = None + if want_version.matches((1, 15)): + req.response.last_modified = trait.created_at + req.response.cache_control = 'no-cache' return req.response @@ -163,6 +186,7 @@ def delete_trait(req): @util.check_accept('application/json') def list_traits(req): context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] filters = {} try: @@ -185,8 +209,11 @@ def list_traits(req): traits = rp_obj.TraitList.get_all(context, filters) req.response.status = 200 - req.response.body = encodeutils.to_utf8( - jsonutils.dumps(_serialize_traits(traits))) + output, last_modified = _serialize_traits(traits, want_version) + if want_version.matches((1, 15)): + req.response.last_modified = last_modified + req.response.cache_control = 'no-cache' + req.response.body = encodeutils.to_utf8(jsonutils.dumps(output)) req.response.content_type = 'application/json' return req.response @@ -196,6 +223,7 @@ def list_traits(req): @util.check_accept('application/json') def list_traits_for_resource_provider(req): context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] uuid = util.wsgi_path_item(req.environ, 'uuid') # Resource provider object is needed for two things: If it is @@ -211,9 +239,13 @@ def list_traits_for_resource_provider(req): {'uuid': uuid, 'error': exc}) traits = rp_obj.TraitList.get_all_by_resource_provider(context, rp) - response_body = _serialize_traits(traits) + response_body, last_modified = _serialize_traits(traits, want_version) response_body["resource_provider_generation"] = rp.generation + if want_version.matches((1, 15)): + req.response.last_modified = last_modified + req.response.cache_control = 'no-cache' + req.response.status = 200 req.response.body = encodeutils.to_utf8(jsonutils.dumps(response_body)) req.response.content_type = 'application/json' @@ -225,6 +257,7 @@ def list_traits_for_resource_provider(req): @util.require_content('application/json') def update_traits_for_resource_provider(req): context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] uuid = util.wsgi_path_item(req.environ, 'uuid') data = util.extract_json(req.body, SET_TRAITS_FOR_RP_SCHEMA) rp_gen = data['resource_provider_generation'] @@ -248,9 +281,12 @@ def update_traits_for_resource_provider(req): resource_provider.set_traits(trait_objs) - response_body = _serialize_traits(trait_objs) + response_body, last_modified = _serialize_traits(trait_objs, want_version) response_body[ 'resource_provider_generation'] = resource_provider.generation + if want_version.matches((1, 15)): + req.response.last_modified = last_modified + req.response.cache_control = 'no-cache' req.response.status = 200 req.response.body = encodeutils.to_utf8(jsonutils.dumps(response_body)) req.response.content_type = 'application/json' diff --git a/nova/api/openstack/placement/handlers/usage.py b/nova/api/openstack/placement/handlers/usage.py index 6349c5352bcd..beec996ef18e 100644 --- a/nova/api/openstack/placement/handlers/usage.py +++ b/nova/api/openstack/placement/handlers/usage.py @@ -13,6 +13,7 @@ from oslo_serialization import jsonutils from oslo_utils import encodeutils +from oslo_utils import timeutils import webob from nova.api.openstack.placement import microversion @@ -64,6 +65,7 @@ def list_usages(req): """ context = req.environ['placement.context'] uuid = util.wsgi_path_item(req.environ, 'uuid') + want_version = req.environ[microversion.MICROVERSION_ENVIRON] # Resource provider object needed for two things: If it is # NotFound we'll get a 404 here, which needs to happen because @@ -85,6 +87,14 @@ def list_usages(req): response.body = encodeutils.to_utf8(jsonutils.dumps( _serialize_usages(resource_provider, usage))) req.response.content_type = 'application/json' + if want_version.matches((1, 15)): + req.response.cache_control = 'no-cache' + # While it would be possible to generate a last-modified time + # based on the collection of allocations that result in a usage + # value (with some spelunking in the SQL) that doesn't align with + # the question that is being asked in a request for usages: What + # is the usage, now? So the last-modified time is set to utcnow. + req.response.last_modified = timeutils.utcnow(with_timezone=True) return req.response @@ -99,6 +109,7 @@ def get_total_usages(req): Return 404 Not Found if the wanted microversion does not match. """ context = req.environ['placement.context'] + want_version = req.environ[microversion.MICROVERSION_ENVIRON] schema = GET_USAGES_SCHEMA_1_9 @@ -115,4 +126,12 @@ def get_total_usages(req): for resource in usages}} response.body = encodeutils.to_utf8(jsonutils.dumps(usages_dict)) req.response.content_type = 'application/json' + if want_version.matches((1, 15)): + req.response.cache_control = 'no-cache' + # While it would be possible to generate a last-modified time + # based on the collection of allocations that result in a usage + # value (with some spelunking in the SQL) that doesn't align with + # the question that is being asked in a request for usages: What + # is the usage, now? So the last-modified time is set to utcnow. + req.response.last_modified = timeutils.utcnow(with_timezone=True) return req.response diff --git a/nova/api/openstack/placement/microversion.py b/nova/api/openstack/placement/microversion.py index d4a6a20cfbb4..030c7a61b8b3 100644 --- a/nova/api/openstack/placement/microversion.py +++ b/nova/api/openstack/placement/microversion.py @@ -54,9 +54,9 @@ VERSIONS = [ # as GET. The 'allocation_requests' format in GET # /allocation_candidates is updated to be the same as well. '1.13', # Adds POST /allocations to set allocations for multiple consumers - # as GET '1.14', # Adds parent and root provider UUID on resource provider # representation and 'in_tree' filter on GET /resource_providers + '1.15', # Include last-modified and cache-control headers ] diff --git a/nova/api/openstack/placement/rest_api_version_history.rst b/nova/api/openstack/placement/rest_api_version_history.rst index 5dc510a18193..380ad36541b5 100644 --- a/nova/api/openstack/placement/rest_api_version_history.rst +++ b/nova/api/openstack/placement/rest_api_version_history.rst @@ -197,3 +197,13 @@ A new ``in_tree=`` parameter is now available in the ``GET /resource-providers`` API call. Supplying a UUID value for the ``in_tree`` parameter will cause all resource providers within the "provider tree" of the provider matching ```` to be returned. + +1.15 Add 'last-modified' and 'cache-control' headers +---------------------------------------------------- + +Throughout the API, 'last-modified' headers have been added to GET responses +and those PUT and POST responses that have bodies. The value is either the +actual last modified time of the most recently modified associated database +entity or the current time if there is no direct mapping to the database. In +addition, 'cache-control: no-cache' headers are added where the 'last-modified' +header has been added to prevent inadvertent caching of resources. diff --git a/nova/api/openstack/placement/util.py b/nova/api/openstack/placement/util.py index efb16dcbc71a..552ba2200607 100644 --- a/nova/api/openstack/placement/util.py +++ b/nova/api/openstack/placement/util.py @@ -17,6 +17,7 @@ import re import jsonschema from oslo_middleware import request_id from oslo_serialization import jsonutils +from oslo_utils import timeutils from oslo_utils import uuidutils import webob @@ -124,6 +125,25 @@ def json_error_formatter(body, status, title, environ): return {'errors': [error_dict]} +def pick_last_modified(last_modified, obj): + """Choose max of last_modified and obj.updated_at or obj.created_at. + + If updated_at is not implemented in `obj` use the current time in UTC. + """ + try: + current_modified = (obj.updated_at or obj.created_at) + except NotImplementedError: + # If updated_at is not implemented, we are looking at objects that + # have not come from the database, so "now" is the right modified + # time. + current_modified = timeutils.utcnow(with_timezone=True) + if last_modified: + last_modified = max(last_modified, current_modified) + else: + last_modified = current_modified + return last_modified + + def require_content(content_type): """Decorator to require a content type in a handler.""" def decorator(f): diff --git a/nova/tests/functional/api/openstack/placement/gabbits/aggregate.yaml b/nova/tests/functional/api/openstack/placement/gabbits/aggregate.yaml index 377a3b62869f..c9c4de00b0dc 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/aggregate.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/aggregate.yaml @@ -65,12 +65,19 @@ tests: status: 200 response_headers: content-type: /application/json/ + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_json_paths: $.aggregates[0]: *agg_1 $.aggregates[1]: *agg_2 - name: get those aggregates GET: $LAST_URL + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_json_paths: $.aggregates.`len`: 2 @@ -117,3 +124,26 @@ tests: - JSON does not validate response_json_paths: $.errors[0].title: Bad Request + +# The next two tests confirm that prior to version 1.15 we do +# not set the cache-control or last-modified headers on either +# PUT or GET. + +- name: put some aggregates v1.14 + PUT: $LAST_URL + request_headers: + openstack-api-version: placement 1.14 + data: + - *agg_1 + - *agg_2 + response_forbidden_headers: + - last-modified + - cache-control + +- name: get those aggregates v1.14 + GET: $LAST_URL + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - last-modified + - cache-control diff --git a/nova/tests/functional/api/openstack/placement/gabbits/allocation-candidates.yaml b/nova/tests/functional/api/openstack/placement/gabbits/allocation-candidates.yaml index 52738b69ccb5..a32115e6883c 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/allocation-candidates.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/allocation-candidates.yaml @@ -83,6 +83,11 @@ tests: # storage show correct capacity and usage $.provider_summaries["$ENVIRON['SS_UUID']"].resources[DISK_GB].capacity: 1900 # 1.0 * 2000 - 100G $.provider_summaries["$ENVIRON['SS_UUID']"].resources[DISK_GB].used: 0 + response_forbidden_headers: + # In the default microversion in this file (1.10) the cache headers + # are not preset. + - cache-control + - last-modified # Verify the 1.12 format of the allocation_requests sub object which # changes from a list-list to dict-ish format. @@ -111,3 +116,13 @@ tests: # Verify that shared storage provider only has DISK_GB listed in the # resource requests, but is listed twice $.allocation_requests..allocations["$ENVIRON['SS_UUID']"].resources[DISK_GB]: [100, 100] + +- name: get allocation candidates cache headers + GET: /allocation_candidates?resources=VCPU:1,MEMORY_MB:1024,DISK_GB:100 + request_headers: + # microversion 1.15 to cause cache headers + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ diff --git a/nova/tests/functional/api/openstack/placement/gabbits/allocations.yaml b/nova/tests/functional/api/openstack/placement/gabbits/allocations.yaml index b732d6be3045..ee3408eea360 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/allocations.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/allocations.yaml @@ -373,6 +373,11 @@ tests: DISK_GB: 2 VCPU: 8 status: 204 + # These headers should not be present in any microversion on PUT + # because there is no response body. + response_forbidden_headers: + - cache-control + - last-modified - name: get those allocations for consumer GET: /allocations/1835b1c9-1c61-45af-9eb3-3e0e9f29487b @@ -409,3 +414,37 @@ tests: - Allocation for resource provider 'be8b9cba-e7db-4a12-a386-99b4242167fe' that does not exist response_json_paths: $.errors[0].title: Bad Request + +- name: get allocations for resource provider with cache headers 1.15 + GET: /resource_providers/fcfa516a-abbe-45d1-8152-d5225d82e596/allocations + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: get allocations for resource provider without cache headers 1.14 + GET: /resource_providers/fcfa516a-abbe-45d1-8152-d5225d82e596/allocations + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - cache-control + - last-modified + +- name: get allocations for consumer with cache headers 1.15 + GET: /allocations/1835b1c9-1c61-45af-9eb3-3e0e9f29487b + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: get allocations for consumer without cache headers 1.14 + GET: /allocations/1835b1c9-1c61-45af-9eb3-3e0e9f29487b + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - cache-control + - last-modified diff --git a/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml b/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml index 1c0dd6fcc5f1..244369bfdc3d 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml @@ -159,3 +159,20 @@ tests: $.errors[0].title: Not Found response_strings: - The resource could not be found. + +- name: root at 1.15 has cache headers + GET: / + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: root at 1.14 no cache headers + GET: / + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - last-modified + - cache-control diff --git a/nova/tests/functional/api/openstack/placement/gabbits/inventory.yaml b/nova/tests/functional/api/openstack/placement/gabbits/inventory.yaml index 0e59eeddf85a..5ab3a8390113 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/inventory.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/inventory.yaml @@ -166,6 +166,13 @@ tests: - name: get that inventory GET: $LOCATION status: 200 + request_headers: + # set microversion to 1.15 to get timestamp headers + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_json_paths: $.resource_provider_generation: 1 $.total: 2048 @@ -175,6 +182,15 @@ tests: $.step_size: 10 $.allocation_ratio: 1.0 +- name: get inventory v1.14 no cache headers + GET: $LAST_URL + status: 200 + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - cache-control + - last-modified + - name: modify the inventory PUT: $LAST_URL request_headers: @@ -310,6 +326,13 @@ tests: - name: get list of inventories GET: /resource_providers/$ENVIRON['RP_UUID']/inventories + request_headers: + # set microversion to 1.15 to get timestamp headers + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_json_paths: $.resource_provider_generation: 2 $.inventories.DISK_GB.total: 2048 @@ -407,6 +430,8 @@ tests: PUT: $LOCATION/inventories request_headers: content-type: application/json + # set microversion to 1.15 to get timestamp headers + openstack-api-version: placement 1.15 data: resource_provider_generation: 0 inventories: @@ -415,6 +440,10 @@ tests: DISK_GB: total: 1024 status: 200 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_json_paths: $.resource_provider_generation: 1 $.inventories.IPV4_ADDRESS.total: 253 diff --git a/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml b/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml index c2e8b6deb438..93110b615527 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml @@ -39,13 +39,13 @@ tests: response_json_paths: $.errors[0].title: Not Acceptable -- name: latest microversion is 1.14 +- name: latest microversion is 1.15 GET: / request_headers: openstack-api-version: placement latest response_headers: vary: /OpenStack-API-Version/ - openstack-api-version: placement 1.14 + openstack-api-version: placement 1.15 - name: other accept header bad version GET: / diff --git a/nova/tests/functional/api/openstack/placement/gabbits/resource-classes-last-modified.yaml b/nova/tests/functional/api/openstack/placement/gabbits/resource-classes-last-modified.yaml new file mode 100644 index 000000000000..e60244777770 --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/gabbits/resource-classes-last-modified.yaml @@ -0,0 +1,117 @@ +# Confirm the behavior and presence of last-modified headers for resource +# classes across multiple microversions. +# +# We have the following routes, with associated microversion, and bodies. +# +# '/resource_classes': { +# 'GET': resource_class.list_resource_classes, +# v1.2, body +# 'POST': resource_class.create_resource_class +# v1.2, no body +# }, +# '/resource_classes/{name}': { +# 'GET': resource_class.get_resource_class, +# v1.2, body +# 'PUT': resource_class.update_resource_class, +# v1.2, body, but time's arrow +# v1.7, no body +# 'DELETE': resource_class.delete_resource_class, +# v1.2, no body +# }, +# +# This means that in 1.15 we only expect last-modified headers for +# the two GET requests, for the other requests we should confirm it +# is not there. + +fixtures: + - APIFixture + +defaults: + request_headers: + x-auth-token: admin + accept: application/json + content-type: application/json + openstack-api-version: placement 1.15 + +tests: + +- name: get resource classes + desc: last modified is now with standards only + GET: /resource_classes + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: create a custom class + PUT: /resource_classes/CUSTOM_MOO_MACHINE + status: 201 + response_forbidden_headers: + - last-modified + - cache-control + +- name: get custom class + GET: $LAST_URL + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: get standard class + GET: /resource_classes/VCPU + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: post a resource class + POST: /resource_classes + data: + name: CUSTOM_ALPHA + status: 201 + response_forbidden_headers: + - last-modified + - cache-control + +- name: get resource classes including custom + desc: last modified will still be now with customs because of standards + GET: /resource_classes + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: put a resource class 1.6 microversion + PUT: /resource_classes/CUSTOM_MOO_MACHINE + request_headers: + openstack-api-version: placement 1.6 + data: + name: CUSTOM_BETA + status: 200 + response_forbidden_headers: + - last-modified + - cache-control + +- name: get resource classes 1.14 microversion + GET: /resource_classes + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - last-modified + - cache-control + +- name: get standard class 1.14 microversion + GET: /resource_classes/VCPU + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - last-modified + - cache-control + +- name: get custom class 1.14 microversion + GET: $LAST_URL + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - last-modified + - cache-control diff --git a/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml b/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml index c0c7f53ce482..caf9c2f8763d 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml @@ -12,8 +12,15 @@ tests: - name: what is at resource providers GET: /resource_providers + request_headers: + # microversion 1.15 for cache headers + openstack-api-version: placement 1.15 response_json_paths: $.resource_providers: [] + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ - name: non admin forbidden GET: /resource_providers @@ -78,6 +85,12 @@ tests: GET: /resource_providers/$ENVIRON['RP_UUID'] request_headers: content-type: application/json + openstack-api-version: placement 1.15 + response_headers: + content-type: application/json + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_json_paths: $.uuid: $ENVIRON['RP_UUID'] $.name: $ENVIRON['RP_NAME'] @@ -104,6 +117,8 @@ tests: - name: list one resource providers GET: /resource_providers + request_headers: + openstack-api-version: placement 1.15 response_json_paths: $.resource_providers.`len`: 1 $.resource_providers[0].uuid: $ENVIRON['RP_UUID'] @@ -113,6 +128,10 @@ tests: $.resource_providers[0].links[?rel = "self"].href: /resource_providers/$ENVIRON['RP_UUID'] $.resource_providers[0].links[?rel = "inventories"].href: /resource_providers/$ENVIRON['RP_UUID']/inventories $.resource_providers[0].links[?rel = "usages"].href: /resource_providers/$ENVIRON['RP_UUID']/usages + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ - name: filter out all resource providers by name GET: /resource_providers?name=flubblebubble @@ -181,11 +200,15 @@ tests: PUT: /resource_providers/$RESPONSE['$.resource_providers[0].uuid'] request_headers: content-type: application/json + openstack-api-version: placement 1.15 data: name: new name status: 200 response_headers: content-type: /application/json/ + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ response_forbidden_headers: - location response_json_paths: @@ -491,3 +514,11 @@ tests: - "Failed validating 'maxLength'" response_json_paths: $.errors[0].title: Bad Request + +- name: confirm no cache-control headers before 1.15 + GET: /resource_providers + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - cache-control + - last-modified diff --git a/nova/tests/functional/api/openstack/placement/gabbits/traits.yaml b/nova/tests/functional/api/openstack/placement/gabbits/traits.yaml index 6300db6d3d19..0d5f911707a9 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/traits.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/traits.yaml @@ -5,7 +5,8 @@ fixtures: defaults: request_headers: x-auth-token: admin - openstack-api-version: placement latest + # traits introduced in 1.6 + openstack-api-version: placement 1.6 tests: @@ -34,6 +35,9 @@ tests: location: //traits/CUSTOM_TRAIT_1/ response_forbidden_headers: - content-type + # PUT in 1.6 version should not have cache headers + - cache-control + - last-modified - name: create a trait which existed PUT: /traits/CUSTOM_TRAIT_1 @@ -48,6 +52,9 @@ tests: status: 204 response_forbidden_headers: - content-type + # In early versions cache headers should not be present + - cache-control + - last-modified - name: get a non-existed trait GET: /traits/NON_EXISTED @@ -56,6 +63,11 @@ tests: - name: delete a trait DELETE: /traits/CUSTOM_TRAIT_1 status: 204 + response_forbidden_headers: + - content-type + # DELETE in any version should not have cache headers + - cache-control + - last-modified - name: delete a non-existed trait DELETE: /traits/CUSTOM_NON_EXSITED @@ -133,6 +145,61 @@ tests: response_strings: - "Invalid query string parameters: Additional properties are not allowed" +- name: list traits 1.14 no cache headers + GET: /traits + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - cache-control + - last-modified + +- name: list traits 1.15 has cache headers + GET: /traits + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: get trait 1.14 no cache headers + GET: /traits/CUSTOM_TRAIT_1 + request_headers: + openstack-api-version: placement 1.14 + status: 204 + response_forbidden_headers: + - cache-control + - last-modified + +- name: get trait 1.15 has cache headers + GET: /traits/CUSTOM_TRAIT_1 + request_headers: + openstack-api-version: placement 1.15 + status: 204 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: put trait 1.14 no cache headers + PUT: /traits/CUSTOM_TRAIT_1 + request_headers: + openstack-api-version: placement 1.14 + status: 204 + response_forbidden_headers: + - cache-control + - last-modified + +- name: put trait 1.15 has cache headers + PUT: /traits/CUSTOM_TRAIT_1 + request_headers: + openstack-api-version: placement 1.15 + status: 204 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + - name: post new resource provider POST: /resource_providers request_headers: @@ -152,6 +219,10 @@ tests: response_json_paths: $.resource_provider_generation: 0 $.traits.`len`: 0 + response_forbidden_headers: + # In 1.6 no cache headers + - cache-control + - last-modified - name: set traits for resource provider PUT: /resource_providers/$ENVIRON['RP_UUID']/traits @@ -169,6 +240,10 @@ tests: response_strings: - CUSTOM_TRAIT_1 - CUSTOM_TRAIT_2 + response_forbidden_headers: + # In 1.6 no cache headers + - cache-control + - last-modified - name: get associated traits GET: /traits?associated=true @@ -272,3 +347,58 @@ tests: status: 404 response_strings: - No resource provider with uuid non_existed found + +- name: empty traits for resource provider 1.15 has cache headers + GET: /resource_providers/$ENVIRON['RP_UUID']/traits + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: update rp trait 1.14 no cache headers + PUT: /resource_providers/$ENVIRON['RP_UUID']/traits + data: + traits: + - CUSTOM_TRAIT_1 + - CUSTOM_TRAIT_2 + resource_provider_generation: 2 + request_headers: + openstack-api-version: placement 1.14 + content-type: application/json + response_forbidden_headers: + - cache-control + - last-modified + +- name: update rp trait 1.15 has cache headers + PUT: /resource_providers/$ENVIRON['RP_UUID']/traits + data: + traits: + - CUSTOM_TRAIT_1 + - CUSTOM_TRAIT_2 + resource_provider_generation: 3 + request_headers: + openstack-api-version: placement 1.15 + content-type: application/json + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + +- name: list traits for resource provider 1.14 no cache headers + GET: /resource_providers/$ENVIRON['RP_UUID']/traits + request_headers: + openstack-api-version: placement 1.14 + response_forbidden_headers: + - cache-control + - last-modified + +- name: list traits for resource provider 1.15 has cache headers + GET: /resource_providers/$ENVIRON['RP_UUID']/traits + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ diff --git a/nova/tests/functional/api/openstack/placement/gabbits/usage.yaml b/nova/tests/functional/api/openstack/placement/gabbits/usage.yaml index b34c0255727e..8fa8995652d9 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/usage.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/usage.yaml @@ -38,6 +38,21 @@ tests: response_json_paths: usages: {} +- name: get usages no cache headers base microversion + GET: $LAST_URL + response_forbidden_headers: + - last-modified + - cache-control + +- name: get usages cache headers 1.15 + GET: $LAST_URL + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ + - name: get total usages earlier version GET: /usages?project_id=$ENVIRON['PROJECT_ID'] request_headers: diff --git a/nova/tests/functional/api/openstack/placement/gabbits/with-allocations.yaml b/nova/tests/functional/api/openstack/placement/gabbits/with-allocations.yaml index eaa498ebe765..d19fb453e033 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/with-allocations.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/with-allocations.yaml @@ -65,6 +65,10 @@ tests: request_headers: openstack-api-version: placement 1.9 status: 200 + # In pre 1.15 microversions cache headers not present + response_forbidden_headers: + - last-modified + - cache-control response_json_paths: $.usages.DISK_GB: 20 $.usages.VCPU: 1 @@ -87,3 +91,12 @@ tests: $.project_id: $ENVIRON['PROJECT_ID'] $.user_id: $ENVIRON['USER_ID'] $.`len`: 3 + +- name: get total usages with cache headers + GET: /usages?project_id=$ENVIRON['PROJECT_ID']&user_id=$ENVIRON['ALT_USER_ID'] + request_headers: + openstack-api-version: placement 1.15 + response_headers: + cache-control: no-cache + # Does last-modified look like a legit timestamp? + last-modified: /^\w+, \d+ \w+ \d{4} [\d:]+ GMT$/ diff --git a/nova/tests/unit/api/openstack/placement/test_handler.py b/nova/tests/unit/api/openstack/placement/test_handler.py index 3e75ccc77287..a83f89149ded 100644 --- a/nova/tests/unit/api/openstack/placement/test_handler.py +++ b/nova/tests/unit/api/openstack/placement/test_handler.py @@ -19,6 +19,7 @@ import webob from nova.api.openstack.placement import handler from nova.api.openstack.placement.handlers import root +from nova.api.openstack.placement import microversion from nova import test from nova.tests import uuidsentinel @@ -35,6 +36,9 @@ def _environ(path='/moo', method='GET'): 'SERVER_NAME': 'example.com', 'SERVER_PORT': '80', 'wsgi.url_scheme': 'http', + # The microversion version value is not used, but it + # needs to be set to avoid a KeyError. + microversion.MICROVERSION_ENVIRON: microversion.Version(1, 12), } diff --git a/nova/tests/unit/api/openstack/placement/test_util.py b/nova/tests/unit/api/openstack/placement/test_util.py index aede250a7237..c7b7cb4e5948 100644 --- a/nova/tests/unit/api/openstack/placement/test_util.py +++ b/nova/tests/unit/api/openstack/placement/test_util.py @@ -13,8 +13,12 @@ """Unit tests for the utility functions used by the placement API.""" +import datetime + import fixtures +import mock from oslo_middleware import request_id +from oslo_utils import timeutils import webob import six.moves.urllib.parse as urlparse @@ -594,3 +598,83 @@ class TestParseQsResourcesAndTraits(test.NoDBTestCase): '&required2=CUSTOM_SWITCH_BIG,CUSTOM_PHYSNET_PROD' '&resources3=CUSTOM_MAGIC:123') self.assertRaises(webob.exc.HTTPBadRequest, self.do_parse, qs) + + +class TestPickLastModified(test.NoDBTestCase): + + def setUp(self): + super(TestPickLastModified, self).setUp() + self.resource_provider = rp_obj.ResourceProvider( + name=uuidsentinel.rp_name, uuid=uuidsentinel.rp_uuid) + + def test_updated_versus_none(self): + now = timeutils.utcnow(with_timezone=True) + self.resource_provider.updated_at = now + self.resource_provider.created_at = now + chosen_time = util.pick_last_modified(None, self.resource_provider) + self.assertEqual(now, chosen_time) + + def test_created_versus_none(self): + now = timeutils.utcnow(with_timezone=True) + self.resource_provider.created_at = now + self.resource_provider.updated_at = None + chosen_time = util.pick_last_modified(None, self.resource_provider) + self.assertEqual(now, chosen_time) + + def test_last_modified_less(self): + now = timeutils.utcnow(with_timezone=True) + less = now - datetime.timedelta(seconds=300) + self.resource_provider.updated_at = now + self.resource_provider.created_at = now + chosen_time = util.pick_last_modified(less, self.resource_provider) + self.assertEqual(now, chosen_time) + + def test_last_modified_more(self): + now = timeutils.utcnow(with_timezone=True) + more = now + datetime.timedelta(seconds=300) + self.resource_provider.updated_at = now + self.resource_provider.created_at = now + chosen_time = util.pick_last_modified(more, self.resource_provider) + self.assertEqual(more, chosen_time) + + def test_last_modified_same(self): + now = timeutils.utcnow(with_timezone=True) + self.resource_provider.updated_at = now + self.resource_provider.created_at = now + chosen_time = util.pick_last_modified(now, self.resource_provider) + self.assertEqual(now, chosen_time) + + def test_no_object_time_fields_less(self): + # An unsaved ovo will not have the created_at or updated_at fields + # present on the object at all. + now = timeutils.utcnow(with_timezone=True) + less = now - datetime.timedelta(seconds=300) + with mock.patch('oslo_utils.timeutils.utcnow') as mock_utc: + mock_utc.return_value = now + chosen_time = util.pick_last_modified( + less, self.resource_provider) + self.assertEqual(now, chosen_time) + mock_utc.assert_called_once_with(with_timezone=True) + + def test_no_object_time_fields_more(self): + # An unsaved ovo will not have the created_at or updated_at fields + # present on the object at all. + now = timeutils.utcnow(with_timezone=True) + more = now + datetime.timedelta(seconds=300) + with mock.patch('oslo_utils.timeutils.utcnow') as mock_utc: + mock_utc.return_value = now + chosen_time = util.pick_last_modified( + more, self.resource_provider) + self.assertEqual(more, chosen_time) + mock_utc.assert_called_once_with(with_timezone=True) + + def test_no_object_time_fields_none(self): + # An unsaved ovo will not have the created_at or updated_at fields + # present on the object at all. + now = timeutils.utcnow(with_timezone=True) + with mock.patch('oslo_utils.timeutils.utcnow') as mock_utc: + mock_utc.return_value = now + chosen_time = util.pick_last_modified( + None, self.resource_provider) + self.assertEqual(now, chosen_time) + mock_utc.assert_called_once_with(with_timezone=True) diff --git a/releasenotes/notes/placement-last-modified-cf43aece4c54fc97.yaml b/releasenotes/notes/placement-last-modified-cf43aece4c54fc97.yaml new file mode 100644 index 000000000000..f97780a56498 --- /dev/null +++ b/releasenotes/notes/placement-last-modified-cf43aece4c54fc97.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Throughout the API, in microversion 1.15, 'last-modified' headers have been + added to GET responses and those PUT and POST responses that have bodies. + The value is either the actual last modified time of the most recently + modified associated database entity or the current time if there is no + direct mapping to the database. In addition, 'cache-control: no-cache' + headers are added where the 'last-modified' header has been added to + prevent inadvertent caching of resources.