Merge "api: Add response body schemas for servers APIs (5/6)"

This commit is contained in:
Zuul
2026-02-25 21:00:49 +00:00
committed by Gerrit Code Review
5 changed files with 408 additions and 59 deletions
@@ -1439,6 +1439,330 @@ create_response = {
],
}
update_response = {
'type': 'object',
'properties': {
'server': {
'type': 'object',
'properties': {
'accessIPv4': {
'type': 'string',
'oneOf': [{'format': 'ipv4'}, {'const': ''}],
},
'accessIPv6': {
'type': 'string',
'oneOf': [{'format': 'ipv6'}, {'const': ''}],
},
'addresses': {
'type': 'object',
'patternProperties': {
'^.+$': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'addr': {
'type': 'string',
'oneOf': [
{'format': 'ipv4'},
{'format': 'ipv6'},
],
},
'version': {
'type': 'number',
'enum': [4, 6],
},
},
'required': ['addr', 'version'],
'additionalProperties': False,
},
},
},
'additionalProperties': False,
},
'adminPass': {'type': ['null', 'string']},
'created': {'type': 'string', 'format': 'date-time'},
'fault': {
'type': 'object',
'properties': {
'code': {'type': 'integer'},
'created': {'type': 'string', 'format': 'date-time'},
'details': {'type': 'string'},
'message': {'type': 'string'},
},
'required': ['code', 'created', 'message'],
'additionalProperties': False,
},
'flavor': {
'type': 'object',
'properties': {
'id': {'type': 'string'},
'links': response_types.links,
},
'additionalProperties': False,
},
'hostId': {'type': 'string'},
'id': {'type': 'string'},
'image': {
'oneOf': [
{'type': 'string', 'const': ''},
{
'type': 'object',
'properties': {
'id': {'type': 'string', 'format': 'uuid'},
'links': response_types.links,
},
'additionalProperties': False,
},
],
},
'links': response_types.links,
'metadata': {
'type': 'object',
'patternProperties': {
'^.+$': {
'type': 'string'
},
},
'additionalProperties': False,
},
'name': {'type': ['string', 'null']},
'progress': {'type': ['null', 'number']},
'status': _server_status,
'tenant_id': parameter_types.project_id,
'updated': {'type': 'string', 'format': 'date-time'},
'user_id': parameter_types.user_id,
'OS-DCF:diskConfig': {'type': 'string'},
},
'required': [
# fault, progress depend on server state
'accessIPv4',
'accessIPv6',
'addresses',
'created',
'flavor',
'hostId',
'id',
'image',
'links',
'metadata',
'name',
'status',
'tenant_id',
'updated',
'user_id',
'OS-DCF:diskConfig',
],
'additionalProperties': False,
},
},
'required': [
'server'
],
'additionalProperties': False,
}
update_response_v29 = copy.deepcopy(update_response)
update_response_v29['properties']['server']['properties']['locked'] = {
'type': 'boolean',
}
update_response_v29['properties']['server']['required'].append('locked')
update_response_v219 = copy.deepcopy(update_response_v29)
update_response_v219['properties']['server']['properties']['description'] = {
'type': ['null', 'string'],
}
update_response_v219['properties']['server']['required'].append('description')
update_response_v226 = copy.deepcopy(update_response_v219)
update_response_v226['properties']['server']['properties']['tags'] = {
'type': 'array',
'items': {'type': 'string'},
'maxItems': 50,
}
update_response_v226['properties']['server']['required'].append('tags')
# NOTE(stephenfin): We overwrite rather than extend 'flavor', since we now
# embed the flavor in this version
update_response_v247 = copy.deepcopy(update_response_v226)
update_response_v247['properties']['server']['properties']['flavor'] = {
'type': 'object',
'properties': {
'disk': {'type': 'integer'},
'ephemeral': {'type': 'integer'},
'extra_specs': {
'type': 'object',
'patternProperties': {
'^.+$': {'type': 'string'},
},
'additionalProperties': False,
},
'original_name': {'type': 'string'},
'ram': {'type': 'integer'},
'swap': {'type': 'integer'},
'vcpus': {'type': 'integer'},
},
'required': ['disk', 'ephemeral', 'original_name', 'ram', 'swap', 'vcpus'],
'additionalProperties': False,
}
update_response_v263 = copy.deepcopy(update_response_v247)
update_response_v263['properties']['server']['properties'].update(
{
'trusted_image_certificates': {
'type': ['array', 'null'],
'items': {'type': 'string'},
},
},
)
update_response_v263['properties']['server']['required'].append(
'trusted_image_certificates'
)
update_response_v271 = copy.deepcopy(update_response_v263)
update_response_v271['properties']['server']['properties'].update(
{
'server_groups': {
'type': 'array',
'items': {'type': 'string', 'format': 'uuid'},
'maxLength': 1,
},
},
)
update_response_v271['properties']['server']['required'].append(
'server_groups'
)
update_response_v273 = copy.deepcopy(update_response_v271)
update_response_v273['properties']['server']['properties'].update(
{
'locked_reason': {'type': ['null', 'string']},
},
)
update_response_v273['properties']['server']['required'].append(
'locked_reason'
)
update_response_v275 = copy.deepcopy(update_response_v273)
update_response_v275['properties']['server']['properties'].update(
{
'config_drive': {
# TODO(stephenfin): Our tests return null but this shouldn't happen
# in practice, apparently?
'type': ['string', 'boolean', 'null'],
},
'host_status': {'type': 'string'},
'key_name': {'type': ['null', 'string']},
'os-extended-volumes:volumes_attached': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'id': {'type': 'string'},
'delete_on_termination': {
'type': 'boolean',
'default': False,
},
},
'required': ['id', 'delete_on_termination'],
'additionalProperties': False,
},
},
'security_groups': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'name': {'type': 'string'},
},
'required': ['name'],
'additionalProperties': False,
},
},
'OS-EXT-AZ:availability_zone': {'type': 'string'},
'OS-EXT-SRV-ATTR:host': {'type': ['string', 'null']},
'OS-EXT-SRV-ATTR:hostname': {'type': 'string'},
'OS-EXT-SRV-ATTR:hypervisor_hostname': {'type': ['string', 'null']},
'OS-EXT-SRV-ATTR:instance_name': {'type': 'string'},
'OS-EXT-SRV-ATTR:kernel_id': {'type': ['string', 'null']},
'OS-EXT-SRV-ATTR:launch_index': {'type': 'integer'},
'OS-EXT-SRV-ATTR:ramdisk_id': {'type': ['string', 'null']},
'OS-EXT-SRV-ATTR:reservation_id': {'type': ['string', 'null']},
'OS-EXT-SRV-ATTR:root_device_name': {'type': ['string', 'null']},
'OS-EXT-SRV-ATTR:user_data': {
'type': ['string', 'null'], 'format': 'base64', 'maxLength': 65535,
},
'OS-EXT-STS:power_state': {
'type': ['integer', 'null'], 'enum': [0, 1, 3, 4, 6, 7, None],
},
'OS-EXT-STS:task_state': {'type': ['string', 'null']},
'OS-EXT-STS:vm_state': {'type': ['string', 'null']},
'OS-SRV-USG:launched_at': {
'type': ['string', 'null'], 'format': 'date-time',
},
'OS-SRV-USG:terminated_at': {
'type': ['string', 'null'], 'format': 'date-time',
},
},
)
update_response_v275['properties']['server']['required'].extend([
'config_drive',
'OS-EXT-AZ:availability_zone',
'OS-EXT-STS:power_state',
'OS-EXT-STS:task_state',
'OS-EXT-STS:vm_state',
'os-extended-volumes:volumes_attached',
'OS-SRV-USG:launched_at',
'OS-SRV-USG:terminated_at',
])
update_response_v275['properties']['server']['properties']['addresses'][
'patternProperties'
]['^.+$']['items']['properties'].update({
'OS-EXT-IPS-MAC:mac_addr': {'type': 'string', 'format': 'mac-address'},
'OS-EXT-IPS:type': {'type': 'string', 'enum': ['fixed', 'floating']},
})
update_response_v275['properties']['server']['properties']['addresses'][
'patternProperties'
]['^.+$']['items']['required'].extend([
'OS-EXT-IPS-MAC:mac_addr', 'OS-EXT-IPS:type'
])
update_response_v296 = copy.deepcopy(update_response_v275)
update_response_v296['properties']['server']['properties'].update({
'pinned_availability_zone': {
'type': ['null', 'string'],
},
})
update_response_v296['properties']['server']['required'].append(
'pinned_availability_zone'
)
update_response_v298 = copy.deepcopy(update_response_v296)
update_response_v298['properties']['server']['properties']['image']['oneOf'][
1
]['properties'].update({
'properties': {
'type': 'object',
'patternProperties': {
'^[a-zA-Z0-9_:. ]{1,255}$': {
'type': ['string', 'null'],
'maxLength': 255,
},
},
'additionalProperties': False,
},
})
update_response_v2100 = copy.deepcopy(update_response_v298)
update_response_v2100['properties']['server']['properties'].update({
'scheduler_hints': _hints,
})
update_response_v2100['properties']['server']['required'].append(
'scheduler_hints'
)
resize_response = {'type': 'null'}
confirm_resize_response = {'type': 'null'}
@@ -1765,6 +2089,7 @@ rebuild_response_v296['properties']['server']['properties'].update({
rebuild_response_v296['properties']['server']['required'].append(
'pinned_availability_zone'
)
rebuild_response_v298 = copy.deepcopy(rebuild_response_v296)
rebuild_response_v298['properties']['server']['properties']['image']['oneOf'][
1
+15 -3
View File
@@ -938,6 +938,18 @@ class ServersController(wsgi.Controller):
@validation.schema(schema.update_v219, '2.19', '2.89')
@validation.schema(schema.update_v290, '2.90', '2.93')
@validation.schema(schema.update_v294, '2.94')
@validation.response_body_schema(schema.update_response, '2.0', '2.8')
@validation.response_body_schema(schema.update_response_v29, '2.9', '2.18')
@validation.response_body_schema(schema.update_response_v219, '2.19', '2.25') # noqa: E501
@validation.response_body_schema(schema.update_response_v226, '2.26', '2.46') # noqa: E501
@validation.response_body_schema(schema.update_response_v247, '2.47', '2.62') # noqa: E501
@validation.response_body_schema(schema.update_response_v263, '2.63', '2.70') # noqa: E501
@validation.response_body_schema(schema.update_response_v271, '2.71', '2.72') # noqa: E501
@validation.response_body_schema(schema.update_response_v273, '2.73', '2.74') # noqa: E501
@validation.response_body_schema(schema.update_response_v275, '2.75', '2.95') # noqa: E501
@validation.response_body_schema(schema.update_response_v296, '2.96', '2.97') # noqa: E501
@validation.response_body_schema(schema.update_response_v298, '2.98', '2.99') # noqa: E501
@validation.response_body_schema(schema.update_response_v2100, '2.100')
def update(self, req, id, body):
"""Update server then pass on to version-specific controller."""
@@ -966,6 +978,9 @@ class ServersController(wsgi.Controller):
try:
instance = self.compute_api.update_instance(
ctxt, instance, update_dict)
except exception.InstanceNotFound:
msg = _("Instance could not be found")
raise exc.HTTPNotFound(explanation=msg)
show_server_groups = api_version_request.is_supported(req, '2.71')
# NOTE(gmann): Starting from microversion 2.75, PUT and Rebuild
@@ -998,9 +1013,6 @@ class ServersController(wsgi.Controller):
show_extended_status=show_extended_status,
show_extended_volumes=show_extended_volumes,
show_server_groups=show_server_groups)
except exception.InstanceNotFound:
msg = _("Instance could not be found")
raise exc.HTTPNotFound(explanation=msg)
# NOTE(gmann): Returns 204 for backwards compatibility but should be 202
# for representing async API as this API just accepts the request and
+7 -6
View File
@@ -197,10 +197,7 @@ def _validate_az_name(instance):
# you have multiple schemas, this method will delete properties that are not
# allowed against earlier subschemas even if they're allowed (or even required)
# by later subschemas.
def _soft_validate_additional_properties(validator,
additional_properties_value,
instance,
schema):
def _soft_validate_additional_properties(validator, value, instance, schema):
"""This validator function is used for legacy v2 compatible mode in v2.1.
This will skip all the additional properties checking but keep check the
'patternProperties'. 'patternProperties' is used for metadata API.
@@ -222,8 +219,7 @@ def _soft_validate_additional_properties(validator,
are patternProperties specified, the extra properties will not be
touched and raise validation error if pattern doesn't match.
"""
if (not validator.is_type(instance, "object") or
additional_properties_value):
if not validator.is_type(instance, "object") or value is True:
return
properties = schema.get("properties", {})
@@ -240,6 +236,11 @@ def _soft_validate_additional_properties(validator,
if not extra_properties:
return
if set(extra_properties) == set(instance):
# NOTE(stephenfin): This is a bit of hack. If there are multiple
# sub-schemas (oneOf), we will expect to match on one but not the other
return
if patterns:
error = "Additional properties are not allowed (%s %s unexpected)"
if len(extra_properties) == 1:
@@ -303,10 +303,6 @@ class KeypairsTestV22(KeypairsTestV21):
self):
pass
def test_create_server_keypair_name_with_leading_trailing_compat_mode(
self):
pass
class KeypairsTestV210(KeypairsTestV22):
wsgi_api_version = '2.10'
@@ -315,10 +311,6 @@ class KeypairsTestV210(KeypairsTestV22):
self):
pass
def test_create_server_keypair_name_with_leading_trailing_compat_mode(
self):
pass
def test_keypair_list_other_user(self):
req = fakes.HTTPRequest.blank(
self.base_url + f'/os-keypairs?user_id={uuids.other_user_id}',
+32 -13
View File
@@ -431,9 +431,13 @@ class ServersPolicyTest(base.BasePolicyTest):
@mock.patch('nova.objects.BlockDeviceMappingList.bdms_by_instance_uuid')
@mock.patch.object(InstanceGroup, 'get_by_instance_uuid')
@mock.patch('nova.compute.api.API.update_instance')
def test_server_update_with_extra_specs_policy(self,
mock_update, mock_group, mock_bdm):
def test_server_update_with_extra_specs_policy(
self, mock_update, mock_group, mock_bdm,
):
mock_update.return_value = self.instance
mock_group.return_value = objects.InstanceGroup(
uuid=uuids.server_group)
rule = policies.SERVERS % 'update'
# server 'update' policy is checked before flavor extra specs
# policy so we have to allow it for everyone otherwise it will fail
@@ -582,6 +586,7 @@ class ServersPolicyTest(base.BasePolicyTest):
@mock.patch('nova.compute.api.API.update_instance')
def test_update_server_policy(self, mock_update):
mock_update.return_value = self.instance
rule_name = policies.SERVERS % 'update'
body = {'server': {'name': 'test'}}
@@ -607,7 +612,10 @@ class ServersPolicyTest(base.BasePolicyTest):
@mock.patch('nova.compute.api.API.update_instance')
def test_update_server_overridden_policy_pass_with_same_user(
self, mock_update):
self, mock_update,
):
mock_update.return_value = self.instance
rule_name = policies.SERVERS % 'update'
self.policy.set_rules({rule_name: "user_id:%(user_id)s"},
overwrite=False)
@@ -977,10 +985,14 @@ class ServersPolicyTest(base.BasePolicyTest):
@mock.patch.object(InstanceGroup, 'get_by_instance_uuid')
@mock.patch('nova.compute.api.API.update_instance')
@mock.patch('nova.compute.api.API.get_instance_host_status')
def test_server_update_with_extended_attr_policy(self,
mock_status, mock_update, mock_group, mock_bdm):
mock_update.return_value = self.instance
def test_server_update_with_extended_attr_policy(
self, mock_status, mock_update, mock_group, mock_bdm
):
mock_status.return_value = fields.HostStatus.UP
mock_update.return_value = self.instance
mock_group.return_value = objects.InstanceGroup(
uuid=uuids.server_group)
rule = policies.SERVERS % 'update'
# server 'update' policy is checked before extended attributes
# policy so we have to allow it for everyone otherwise it will fail
@@ -1075,10 +1087,14 @@ class ServersPolicyTest(base.BasePolicyTest):
@mock.patch.object(InstanceGroup, 'get_by_instance_uuid')
@mock.patch('nova.compute.api.API.update_instance')
@mock.patch('nova.compute.api.API.get_instance_host_status')
def test_server_update_with_host_status_policy(self,
mock_status, mock_update, mock_group, mock_bdm):
mock_update.return_value = self.instance
def test_server_update_with_host_status_policy(
self, mock_status, mock_update, mock_group, mock_bdm,
):
mock_status.return_value = fields.HostStatus.UP
mock_update.return_value = self.instance
mock_group.return_value = objects.InstanceGroup(
uuid=uuids.server_group)
rule = policies.SERVERS % 'update'
# server 'update' policy is checked before host_status
# policy so we have to allow it for everyone otherwise it will fail
@@ -1192,10 +1208,14 @@ class ServersPolicyTest(base.BasePolicyTest):
@mock.patch('nova.compute.api.API.get_instance_host_status')
@mock.patch.object(InstanceGroup, 'get_by_instance_uuid')
@mock.patch('nova.compute.api.API.update_instance')
def test_server_update_with_unknown_host_status_policy(self,
mock_update, mock_group, mock_status, mock_bdm):
def test_server_update_with_unknown_host_status_policy(
self, mock_update, mock_group, mock_status, mock_bdm,
):
mock_update.return_value = self.instance
mock_status.return_value = fields.HostStatus.UNKNOWN
mock_group.return_value = objects.InstanceGroup(
uuid=uuids.server_group)
rule = policies.SERVERS % 'update'
# server 'update' policy is checked before unknown host_status
# policy so we have to allow it for everyone otherwise it will fail
@@ -1221,8 +1241,7 @@ class ServersPolicyTest(base.BasePolicyTest):
self.assertNotIn('host_status', resp['server'])
@mock.patch('nova.compute.api.API.create')
def test_create_requested_destination_server_policy(self,
mock_create):
def test_create_requested_destination_server_policy(self, mock_create):
# 'create' policy is checked before 'create:requested_destination' so
# we have to allow it for everyone otherwise it will
# fail for unauthorized contexts here.