Merge "api: Add response body schemas for simple tenant usage APIs"

This commit is contained in:
Zuul
2025-12-15 18:19:03 +00:00
committed by Gerrit Code Review
3 changed files with 174 additions and 35 deletions

View File

@@ -11,9 +11,12 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import copy import copy
from nova.api.validation import parameter_types from nova.api.validation import parameter_types
from nova.api.validation import response_types
from nova.compute import vm_states
index_query = { index_query = {
@@ -56,3 +59,127 @@ index_query_v275['additionalProperties'] = False
show_query_v275 = copy.deepcopy(show_query_v240) show_query_v275 = copy.deepcopy(show_query_v240)
show_query_v275['additionalProperties'] = False show_query_v275['additionalProperties'] = False
_server_usage_response = {
'type': 'object',
'properties': {
'ended_at': {'type': ['string', 'null'], 'format': 'date-time'},
'flavor': {'type': 'string'},
'hours': {'type': 'number'},
'instance_id': {'type': 'string', 'format': 'uuid'},
'local_gb': {'type': 'integer', 'minimum': 0},
'memory_mb': {'type': 'integer', 'minimum': 1},
'name': {'type': 'string'},
'started_at': {'type': 'string', 'format': 'date-time'},
'state': {
'type': 'string',
'enum': [
vm_states.ACTIVE,
vm_states.BUILDING,
# vm_states.DELETED is ignored in favour of 'terminated'
vm_states.ERROR,
vm_states.PAUSED,
vm_states.RESCUED,
vm_states.RESIZED,
vm_states.SHELVED,
vm_states.SHELVED_OFFLOADED,
# vm_states.SOFT_DELETED is ignored in favour of 'terminated'
vm_states.STOPPED,
vm_states.SUSPENDED,
'terminated',
],
},
'tenant_id': parameter_types.project_id,
'uptime': {'type': 'integer', 'minimum': 0},
'vcpus': {'type': 'integer', 'minimum': 1},
},
'required': [
# local_gb, memory_mb and vcpus can be omitted if the instance is not
# found
'ended_at',
'flavor',
'hours',
'instance_id',
'name',
'state',
'started_at',
'tenant_id',
'uptime',
],
'additionalProperties': False,
}
_usage_response = {
'type': 'object',
'properties': {
'server_usages': {
'type': 'array',
'items': _server_usage_response,
},
'start': {'type': 'string', 'format': 'date-time'},
'stop': {'type': 'string', 'format': 'date-time'},
'tenant_id': parameter_types.project_id,
# these are number instead of integer since the underlying values are
# floats after multiplication by hours (a float)
'total_hours': {'type': 'number', 'minimum': 0},
'total_local_gb_usage': {'type': 'number', 'minimum': 0},
'total_memory_mb_usage': {'type': 'number', 'minimum': 0},
'total_vcpus_usage': {'type': 'number', 'minimum': 0},
},
'required': [
'start',
'stop',
'tenant_id',
'total_hours',
'total_local_gb_usage',
'total_memory_mb_usage',
'total_vcpus_usage',
],
'additionalProperties': False,
}
index_response = {
'type': 'object',
'properties': {
'tenant_usages': {
'type': 'array',
'items': _usage_response,
},
},
'required': ['tenant_usages'],
'additionalProperties': False,
}
index_response_v240 = copy.deepcopy(index_response)
index_response_v240['properties']['tenant_usages_links'] = (
response_types.collection_links
)
show_response = {
'type': 'object',
# if there are no usages for the tenant, we return an empty object rather
# than an object with all zero values, thus, oneOf
'properties': {
'tenant_usage': {
'oneOf': [
copy.deepcopy(_usage_response),
{
'type': 'object',
'properties': {},
'required': [],
'additionalProperties': False,
},
],
},
},
'required': ['tenant_usage'],
'additionalProperties': False,
}
show_response['properties']['tenant_usage']['oneOf'][0]['required'].append(
'server_usages'
)
show_response_v240 = copy.deepcopy(show_response)
show_response_v240['properties']['tenant_usage_links'] = (
response_types.collection_links
)

View File

@@ -43,6 +43,7 @@ def parse_strtime(dstr, fmt):
raise exception.InvalidStrTime(reason=str(e)) raise exception.InvalidStrTime(reason=str(e))
@validation.validated
class SimpleTenantUsageController(wsgi.Controller): class SimpleTenantUsageController(wsgi.Controller):
_view_builder_class = usages_view.ViewBuilder _view_builder_class = usages_view.ViewBuilder
@@ -246,7 +247,7 @@ class SimpleTenantUsageController(wsgi.Controller):
value = value.replace(tzinfo=datetime.timezone.utc) value = value.replace(tzinfo=datetime.timezone.utc)
return value return value
def _get_datetime_range(self, req): def _parse_qs_params(self, req):
qs = req.environ.get('QUERY_STRING', '') qs = req.environ.get('QUERY_STRING', '')
env = urlparse.parse_qs(qs) env = urlparse.parse_qs(qs)
# NOTE(lzyeval): env.get() always returns a list # NOTE(lzyeval): env.get() always returns a list
@@ -264,6 +265,8 @@ class SimpleTenantUsageController(wsgi.Controller):
@validation.query_schema(schema.index_query, '2.1', '2.39') @validation.query_schema(schema.index_query, '2.1', '2.39')
@validation.query_schema(schema.index_query_v240, '2.40', '2.74') @validation.query_schema(schema.index_query_v240, '2.40', '2.74')
@validation.query_schema(schema.index_query_v275, '2.75') @validation.query_schema(schema.index_query_v275, '2.75')
@validation.response_body_schema(schema.index_response, '2.1', '2.39')
@validation.response_body_schema(schema.index_response_v240, '2.40')
@wsgi.expected_errors(400) @wsgi.expected_errors(400)
def index(self, req): def index(self, req):
"""Retrieve tenant_usage for all tenants.""" """Retrieve tenant_usage for all tenants."""
@@ -279,8 +282,7 @@ class SimpleTenantUsageController(wsgi.Controller):
context.can(stu_policies.POLICY_ROOT % 'list') context.can(stu_policies.POLICY_ROOT % 'list')
try: try:
(period_start, period_stop, detailed) = self._get_datetime_range( period_start, period_stop, detailed = self._parse_qs_params(req)
req)
except exception.InvalidStrTime as e: except exception.InvalidStrTime as e:
raise exc.HTTPBadRequest(explanation=e.format_message()) raise exc.HTTPBadRequest(explanation=e.format_message())
@@ -312,6 +314,8 @@ class SimpleTenantUsageController(wsgi.Controller):
@validation.query_schema(schema.show_query, '2.1', '2.39') @validation.query_schema(schema.show_query, '2.1', '2.39')
@validation.query_schema(schema.show_query_v240, '2.40', '2.74') @validation.query_schema(schema.show_query_v240, '2.40', '2.74')
@validation.query_schema(schema.show_query_v275, '2.75') @validation.query_schema(schema.show_query_v275, '2.75')
@validation.response_body_schema(schema.show_response, '2.1', '2.39')
@validation.response_body_schema(schema.show_response_v240, '2.40')
@wsgi.expected_errors(400) @wsgi.expected_errors(400)
def show(self, req, id): def show(self, req, id):
"""Retrieve tenant_usage for a specified tenant.""" """Retrieve tenant_usage for a specified tenant."""
@@ -329,8 +333,7 @@ class SimpleTenantUsageController(wsgi.Controller):
{'project_id': tenant_id}) {'project_id': tenant_id})
try: try:
(period_start, period_stop, ignore) = self._get_datetime_range( period_start, period_stop, ignore = self._parse_qs_params(req)
req)
except exception.InvalidStrTime as e: except exception.InvalidStrTime as e:
raise exc.HTTPBadRequest(explanation=e.format_message()) raise exc.HTTPBadRequest(explanation=e.format_message())

View File

@@ -115,16 +115,19 @@ def fake_get_active_deleted_flavorless(cls, context, begin, end=None,
limit=None, marker=None): limit=None, marker=None):
# First get some normal instances to have actual usage # First get some normal instances to have actual usage
instances = [ instances = [
_fake_instance(START, STOP, x, _fake_instance(
project_id or 'faketenant_%s' % (x // SERVERS)) START, STOP, x,
for x in range(TENANTS * SERVERS)] project_id or getattr(uuids, 'faketenant_%s' % (x // SERVERS))
) for x in range(TENANTS * SERVERS)
]
# Then get some deleted instances with no flavor to test bugs 1643444 and # Then get some deleted instances with no flavor to test bugs 1643444 and
# 1692893 (duplicates) # 1692893 (duplicates)
instances.extend([ instances.extend([
_fake_instance_deleted_flavorless( _fake_instance_deleted_flavorless(
context, START, STOP, x, context, START, STOP, x,
project_id or 'faketenant_%s' % (x // SERVERS)) project_id or getattr(uuids, 'faketenant_%s' % (x // SERVERS))
for x in range(TENANTS * SERVERS)]) ) for x in range(TENANTS * SERVERS)
])
return objects.InstanceList(objects=instances) return objects.InstanceList(objects=instances)
@@ -134,9 +137,10 @@ def fake_get_active_by_window_joined(cls, context, begin, end=None,
expected_attrs=None, use_slave=False, expected_attrs=None, use_slave=False,
limit=None, marker=None): limit=None, marker=None):
return objects.InstanceList(objects=[ return objects.InstanceList(objects=[
_fake_instance(START, STOP, x, _fake_instance(
project_id or 'faketenant_%s' % (x // SERVERS)) START, STOP, x,
for x in range(TENANTS * SERVERS)]) project_id or getattr(uuids, 'faketenant_%s' % (x // SERVERS))
) for x in range(TENANTS * SERVERS)])
class SimpleTenantUsageTestV21(test.TestCase): class SimpleTenantUsageTestV21(test.TestCase):
@@ -146,15 +150,18 @@ class SimpleTenantUsageTestV21(test.TestCase):
def setUp(self): def setUp(self):
super(SimpleTenantUsageTestV21, self).setUp() super(SimpleTenantUsageTestV21, self).setUp()
self.admin_context = context.RequestContext('fakeadmin_0', self.admin_context = context.RequestContext(
'faketenant_0', uuids.fakeadmin_0,
is_admin=True) uuids.faketenant_0,
self.user_context = context.RequestContext('fakeadmin_0', is_admin=True)
'faketenant_0', self.user_context = context.RequestContext(
is_admin=False) uuids.fakeadmin_0,
self.alt_user_context = context.RequestContext('fakeadmin_0', uuids.faketenant_0,
'faketenant_1', is_admin=False)
is_admin=False) self.alt_user_context = context.RequestContext(
uuids.fakeadmin_0,
uuids.faketenant_1,
is_admin=False)
self.num_cells = len(objects.CellMappingList.get_all( self.num_cells = len(objects.CellMappingList.get_all(
self.admin_context)) self.admin_context))
@@ -276,7 +283,7 @@ class SimpleTenantUsageTestV21(test.TestCase):
@mock.patch('nova.objects.InstanceList.get_active_by_window_joined', @mock.patch('nova.objects.InstanceList.get_active_by_window_joined',
fake_get_active_by_window_joined) fake_get_active_by_window_joined)
def _test_verify_show(self, start, stop, limit=None): def _test_verify_show(self, start, stop, limit=None):
tenant_id = 1 tenant_id = uuids.tenant_id
url = '?start=%s&end=%s' url = '?start=%s&end=%s'
if limit: if limit:
url += '&limit=%s' % (limit) url += '&limit=%s' % (limit)
@@ -319,22 +326,24 @@ class SimpleTenantUsageTestV21(test.TestCase):
(future.isoformat(), NOW.isoformat()), (future.isoformat(), NOW.isoformat()),
version=self.version) version=self.version)
req.environ['nova.context'] = self.user_context req.environ['nova.context'] = self.user_context
self.assertRaises(webob.exc.HTTPBadRequest, self.assertRaises(
self.controller.show, req, 'faketenant_0') webob.exc.HTTPBadRequest,
self.controller.show, req, uuids.faketenant_0)
def test_get_tenants_usage_with_invalid_start_date(self): def test_get_tenants_usage_with_invalid_start_date(self):
req = fakes.HTTPRequest.blank('?start=%s&end=%s' % req = fakes.HTTPRequest.blank('?start=%s&end=%s' %
("xxxx", NOW.isoformat()), ("xxxx", NOW.isoformat()),
version=self.version) version=self.version)
req.environ['nova.context'] = self.user_context req.environ['nova.context'] = self.user_context
self.assertRaises(webob.exc.HTTPBadRequest, self.assertRaises(
self.controller.show, req, 'faketenant_0') webob.exc.HTTPBadRequest,
self.controller.show, req, uuids.faketenant_0)
def _test_get_tenants_usage_with_one_date(self, date_url_param): def _test_get_tenants_usage_with_one_date(self, date_url_param):
req = fakes.HTTPRequest.blank('?%s' % date_url_param, req = fakes.HTTPRequest.blank('?%s' % date_url_param,
version=self.version) version=self.version)
req.environ['nova.context'] = self.user_context req.environ['nova.context'] = self.user_context
res = self.controller.show(req, 'faketenant_0') res = self.controller.show(req, uuids.faketenant_0)
self.assertIn('tenant_usage', res) self.assertIn('tenant_usage', res)
def test_get_tenants_usage_with_no_start_date(self): def test_get_tenants_usage_with_no_start_date(self):
@@ -382,7 +391,7 @@ class SimpleTenantUsageTestV21(test.TestCase):
(START.isoformat(), param, value, param, value), (START.isoformat(), param, value, param, value),
version=self.version) version=self.version)
res = self.controller.show(req, 1) res = self.controller.show(req, uuids.tenant_id)
self.assertIn('tenant_usage', res) self.assertIn('tenant_usage', res)
def test_show_duplicate_query_parameters_validation(self): def test_show_duplicate_query_parameters_validation(self):
@@ -453,15 +462,16 @@ class SimpleTenantUsageTestV2_75(SimpleTenantUsageTestV40):
req = fakes.HTTPRequest.blank('?start=%s&end=%s&additional=1' % req = fakes.HTTPRequest.blank('?start=%s&end=%s&additional=1' %
(START.isoformat(), STOP.isoformat()), (START.isoformat(), STOP.isoformat()),
version='2.74') version='2.74')
res = self.controller.show(req, 1) res = self.controller.show(req, uuids.tenant_id)
self.assertIn('tenant_usage', res) self.assertIn('tenant_usage', res)
def test_show_additional_query_parameters(self): def test_show_additional_query_parameters(self):
req = fakes.HTTPRequest.blank('?start=%s&end=%s&additional=1' % req = fakes.HTTPRequest.blank('?start=%s&end=%s&additional=1' %
(START.isoformat(), STOP.isoformat()), (START.isoformat(), STOP.isoformat()),
version=self.version) version=self.version)
self.assertRaises(exception.ValidationError, self.controller.show, self.assertRaises(
req, 1) exception.ValidationError, self.controller.show,
req, uuids.tenant_id)
class SimpleTenantUsageLimitsTestV21(test.TestCase): class SimpleTenantUsageLimitsTestV21(test.TestCase):
@@ -470,7 +480,6 @@ class SimpleTenantUsageLimitsTestV21(test.TestCase):
def setUp(self): def setUp(self):
super(SimpleTenantUsageLimitsTestV21, self).setUp() super(SimpleTenantUsageLimitsTestV21, self).setUp()
self.controller = simple_tenant_usage_v21.SimpleTenantUsageController() self.controller = simple_tenant_usage_v21.SimpleTenantUsageController()
self.tenant_id = 1
def _get_request(self, url): def _get_request(self, url):
url = url % (START.isoformat(), STOP.isoformat()) url = url % (START.isoformat(), STOP.isoformat())
@@ -484,7 +493,7 @@ class SimpleTenantUsageLimitsTestV21(test.TestCase):
@mock.patch('nova.objects.InstanceList.get_active_by_window_joined') @mock.patch('nova.objects.InstanceList.get_active_by_window_joined')
def test_limit_defaults_to_conf_max_limit_show(self, mock_get): def test_limit_defaults_to_conf_max_limit_show(self, mock_get):
req = self._get_request('?start=%s&end=%s') req = self._get_request('?start=%s&end=%s')
self.controller.show(req, self.tenant_id) self.controller.show(req, uuids.tenant_id)
self.assert_limit(mock_get, CONF.api.max_limit) self.assert_limit(mock_get, CONF.api.max_limit)
@mock.patch('nova.objects.InstanceList.get_active_by_window_joined') @mock.patch('nova.objects.InstanceList.get_active_by_window_joined')
@@ -506,7 +515,7 @@ class SimpleTenantUsageLimitsTestV240(SimpleTenantUsageLimitsTestV21):
@mock.patch('nova.objects.InstanceList.get_active_by_window_joined') @mock.patch('nova.objects.InstanceList.get_active_by_window_joined')
def test_limit_and_marker_show(self, mock_get): def test_limit_and_marker_show(self, mock_get):
req = self._get_request('?start=%s&end=%s&limit=3&marker=some-marker') req = self._get_request('?start=%s&end=%s&limit=3&marker=some-marker')
self.controller.show(req, self.tenant_id) self.controller.show(req, uuids.tenant_id)
self.assert_limit_and_marker(mock_get, 3, 'some-marker') self.assert_limit_and_marker(mock_get, 3, 'some-marker')
@mock.patch('nova.objects.InstanceList.get_active_by_window_joined') @mock.patch('nova.objects.InstanceList.get_active_by_window_joined')