Skip to content

Commit 5453a3e

Browse files
Zuulopenstack-gerrit
authored andcommitted
Merge "api: Align availability zone info with forced host" into stable/wallaby
2 parents 732caaf + 5878240 commit 5453a3e

File tree

4 files changed

+217
-25
lines changed

4 files changed

+217
-25
lines changed

nova/api/openstack/compute/servers.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,84 @@ def _process_networks_for_create(
545545

546546
create_kwargs['requested_networks'] = requested_networks
547547

548+
@staticmethod
549+
def _validate_host_availability_zone(context, availability_zone, host):
550+
"""Ensure the host belongs in the availability zone.
551+
552+
This is slightly tricky and it's probably worth recapping how host
553+
aggregates and availability zones are related before reading. Hosts can
554+
belong to zero or more host aggregates, but they will always belong to
555+
exactly one availability zone. If the user has set the availability
556+
zone key on one of the host aggregates that the host is a member of
557+
then the host will belong to this availability zone. If the user has
558+
not set the availability zone key on any of the host aggregates that
559+
the host is a member of or the host is not a member of any host
560+
aggregates, then the host will belong to the default availability zone.
561+
Setting the availability zone key on more than one of host aggregates
562+
that the host is a member of is an error and will be rejected by the
563+
API.
564+
565+
Given the above, our host-availability zone check needs to vary
566+
behavior based on whether we're requesting the default availability
567+
zone or not. If we are not, then we simply ask "does this host belong
568+
to a host aggregate and, if so, do any of the host aggregates have the
569+
requested availability zone metadata set". By comparison, if we *are*
570+
requesting the default availability zone then we want to ask the
571+
inverse, or "does this host not belong to a host aggregate or, if it
572+
does, is the availability zone information unset (or, naughty naughty,
573+
set to the default) for each of the host aggregates". If both cases, if
574+
the answer is no then we warn about the mismatch and then use the
575+
actual availability zone of the host to avoid mismatches.
576+
577+
:param context: The nova auth request context
578+
:param availability_zone: The name of the requested availability zone
579+
:param host: The name of the requested host
580+
:returns: The availability zone that should actually be used for the
581+
request
582+
"""
583+
aggregates = objects.AggregateList.get_by_host(context, host=host)
584+
if not aggregates:
585+
# a host is assigned to the default availability zone if it is not
586+
# a member of any host aggregates
587+
if availability_zone == CONF.default_availability_zone:
588+
return availability_zone
589+
590+
LOG.warning(
591+
"Requested availability zone '%s' but forced host '%s' "
592+
"does not belong to any availability zones; ignoring "
593+
"requested availability zone to avoid bug #1934770",
594+
availability_zone, host,
595+
)
596+
return None
597+
598+
# only one host aggregate will have the availability_zone field set so
599+
# use the first non-null value
600+
host_availability_zone = next(
601+
(a.availability_zone for a in aggregates if a.availability_zone),
602+
None,
603+
)
604+
605+
if availability_zone == host_availability_zone:
606+
# if there's an exact match, use what the user requested
607+
return availability_zone
608+
609+
if (
610+
availability_zone == CONF.default_availability_zone and
611+
host_availability_zone is None
612+
):
613+
# special case the default availability zone since this won't (or
614+
# rather shouldn't) be explicitly stored on any host aggregate
615+
return availability_zone
616+
617+
# no match, so use the host's availability zone information, if any
618+
LOG.warning(
619+
"Requested availability zone '%s' but forced host '%s' "
620+
"does not belong to this availability zone; overwriting "
621+
"requested availability zone to avoid bug #1934770",
622+
availability_zone, host,
623+
)
624+
return None
625+
548626
@staticmethod
549627
def _process_hosts_for_create(
550628
context, target, server_dict, create_kwargs, host, node):
@@ -665,6 +743,8 @@ def create(self, req, body):
665743
if host or node:
666744
context.can(server_policies.SERVERS % 'create:forced_host',
667745
target=target)
746+
availability_zone = self._validate_host_availability_zone(
747+
context, availability_zone, host)
668748

669749
if api_version_request.is_supported(req, min_version='2.74'):
670750
self._process_hosts_for_create(context, target, server_dict,

nova/compute/api.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,17 +2036,15 @@ def create(self, context, instance_type,
20362036
self._check_multiple_instances_with_neutron_ports(
20372037
requested_networks)
20382038

2039-
if availability_zone:
2040-
available_zones = availability_zones.\
2041-
get_availability_zones(context.elevated(), self.host_api,
2042-
get_only_available=True)
2043-
if forced_host is None and availability_zone not in \
2044-
available_zones:
2039+
if availability_zone and forced_host is None:
2040+
azs = availability_zones.get_availability_zones(
2041+
context.elevated(), self.host_api, get_only_available=True)
2042+
if availability_zone not in azs:
20452043
msg = _('The requested availability zone is not available')
20462044
raise exception.InvalidRequest(msg)
20472045

20482046
filter_properties = scheduler_utils.build_filter_properties(
2049-
scheduler_hints, forced_host, forced_node, instance_type)
2047+
scheduler_hints, forced_host, forced_node, instance_type)
20502048

20512049
return self._create_instance(
20522050
context, instance_type,

nova/tests/unit/api/openstack/compute/test_servers.py

Lines changed: 129 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4563,21 +4563,144 @@ def test_create_instance_name_all_blank_spaces(self):
45634563
self.assertRaises(exception.ValidationError,
45644564
self.controller.create, req, body=body)
45654565

4566-
def test_create_az_with_leading_trailing_spaces(self):
4566+
def test_create_instance_az_with_leading_trailing_spaces(self):
45674567
self.body['server']['availability_zone'] = ' zone1 '
45684568
self.req.body = jsonutils.dump_as_bytes(self.body)
45694569
self.assertRaises(exception.ValidationError,
45704570
self.controller.create, self.req, body=self.body)
45714571

4572-
def test_create_az_with_leading_trailing_spaces_in_compat_mode(
4573-
self):
4572+
def test_create_instance_az_with_leading_trailing_spaces_compat_mode(self):
45744573
self.body['server']['name'] = ' abc def '
4575-
self.body['server']['availability_zones'] = ' zone1 '
4574+
self.body['server']['availability_zone'] = ' zone1 '
45764575
self.req.body = jsonutils.dump_as_bytes(self.body)
45774576
self.req.set_legacy_v2()
4578-
with mock.patch.object(availability_zones, 'get_availability_zones',
4579-
return_value=[' zone1 ']):
4577+
with mock.patch.object(
4578+
availability_zones, 'get_availability_zones',
4579+
return_value=[' zone1 '],
4580+
) as mock_get_azs:
45804581
self.controller.create(self.req, body=self.body)
4582+
mock_get_azs.assert_called_once()
4583+
4584+
def test_create_instance_invalid_az(self):
4585+
self.body['server']['availability_zone'] = 'zone1'
4586+
self.req.body = jsonutils.dump_as_bytes(self.body)
4587+
with mock.patch.object(
4588+
availability_zones, 'get_availability_zones',
4589+
return_value=['zone2'],
4590+
) as mock_get_azs:
4591+
self.assertRaises(
4592+
webob.exc.HTTPBadRequest,
4593+
self.controller.create,
4594+
self.req, body=self.body)
4595+
mock_get_azs.assert_called_once()
4596+
4597+
@mock.patch.object(objects.AggregateList, 'get_by_host')
4598+
@mock.patch.object(servers, 'LOG')
4599+
def test_create_instance_az_host(self, mock_log, mock_get_host_aggs):
4600+
"""Ensure we handle az:host format for 'availability_zone'."""
4601+
mock_get_host_aggs.return_value = objects.AggregateList(
4602+
objects=[
4603+
objects.Aggregate(metadata={'availability_zone': 'zone1'}),
4604+
],
4605+
)
4606+
4607+
self.body['server']['availability_zone'] = 'zone1:host1'
4608+
self.req.body = jsonutils.dump_as_bytes(self.body)
4609+
4610+
self.controller.create(self.req, body=self.body)
4611+
4612+
mock_get_host_aggs.assert_called_once()
4613+
mock_log.warning.assert_not_called()
4614+
4615+
@mock.patch.object(objects.AggregateList, 'get_by_host')
4616+
@mock.patch.object(servers, 'LOG')
4617+
def test_create_instance_az_host_mismatch_without_aggs(
4618+
self, mock_log, mock_get_host_aggs,
4619+
):
4620+
"""User requests an AZ but the host doesn't have one"""
4621+
mock_get_host_aggs.return_value = objects.AggregateList(objects=[])
4622+
4623+
self.body['server']['availability_zone'] = 'zone1:host1'
4624+
self.req.body = jsonutils.dump_as_bytes(self.body)
4625+
4626+
self.controller.create(self.req, body=self.body)
4627+
4628+
mock_get_host_aggs.assert_called_once()
4629+
# we should see a log since the host doesn't belong to the requested AZ
4630+
self.assertIn('bug #1934770', mock_log.warning.call_args[0][0])
4631+
4632+
@mock.patch.object(objects.AggregateList, 'get_by_host')
4633+
@mock.patch.object(servers, 'LOG')
4634+
def test_create_instance_az_host_mismatch_without_aggs_in_default_az(
4635+
self, mock_log, mock_get_host_aggs,
4636+
):
4637+
"""User requests the default AZ and host isn't in any explicit AZ"""
4638+
self.flags(default_availability_zone='zone1')
4639+
mock_get_host_aggs.return_value = objects.AggregateList(objects=[])
4640+
4641+
self.body['server']['availability_zone'] = 'zone1:host1'
4642+
self.req.body = jsonutils.dump_as_bytes(self.body)
4643+
4644+
self.controller.create(self.req, body=self.body)
4645+
4646+
mock_get_host_aggs.assert_called_once()
4647+
# we shouldn't see a log since the host is not in any aggregate and
4648+
# therefore is in the default AZ
4649+
mock_log.warning.assert_not_called()
4650+
4651+
@mock.patch.object(objects.AggregateList, 'get_by_host')
4652+
@mock.patch.object(servers, 'LOG')
4653+
def test_create_instance_az_host_mismatch_with_aggs(
4654+
self, mock_log, mock_get_host_aggs,
4655+
):
4656+
"""User requests an AZ but the host has a different one."""
4657+
mock_get_host_aggs.return_value = objects.AggregateList(
4658+
objects=[
4659+
objects.Aggregate(metadata={'availability_zone': 'zone2'}),
4660+
],
4661+
)
4662+
4663+
self.body['server']['availability_zone'] = 'zone1:host1'
4664+
self.req.body = jsonutils.dump_as_bytes(self.body)
4665+
4666+
self.controller.create(self.req, body=self.body)
4667+
4668+
mock_get_host_aggs.assert_called_once()
4669+
# we should see a log since the host belongs to a different AZ
4670+
self.assertIn('bug #1934770', mock_log.warning.call_args[0][0])
4671+
4672+
@mock.patch.object(objects.AggregateList, 'get_by_host')
4673+
@mock.patch.object(servers, 'LOG')
4674+
def test_create_instance_az_host_mismatch_with_aggs_in_default_az(
4675+
self, mock_log, mock_get_host_aggs,
4676+
):
4677+
"""User requests the default AZ and host is in aggregates without AZ"""
4678+
self.flags(default_availability_zone='zone1')
4679+
mock_get_host_aggs.return_value = objects.AggregateList(
4680+
objects=[objects.Aggregate(metadata={})],
4681+
)
4682+
4683+
self.body['server']['availability_zone'] = 'zone1:host1'
4684+
self.req.body = jsonutils.dump_as_bytes(self.body)
4685+
4686+
self.controller.create(self.req, body=self.body)
4687+
4688+
mock_get_host_aggs.assert_called_once()
4689+
# we shouldn't see a log since none of the host aggregates have an
4690+
# explicit AZ set and the host is therefore in the default AZ
4691+
mock_log.warning.assert_not_called()
4692+
4693+
def test_create_instance_invalid_az_format(self):
4694+
self.body['server']['availability_zone'] = 'invalid::::zone'
4695+
self.assertRaises(webob.exc.HTTPBadRequest,
4696+
self.controller.create,
4697+
self.req, body=self.body)
4698+
4699+
def test_create_instance_invalid_az_as_int(self):
4700+
self.body['server']['availability_zone'] = 123
4701+
self.assertRaises(exception.ValidationError,
4702+
self.controller.create,
4703+
self.req, body=self.body)
45814704

45824705
def test_create_instance(self):
45834706
self.stub_out('uuid.uuid4', lambda: FAKE_UUID)
@@ -6181,18 +6304,6 @@ def test_create_instance_raise_image_bad_request(self, mock_create):
61816304
self.controller.create,
61826305
self.req, body=self.body)
61836306

6184-
def test_create_instance_invalid_availability_zone(self):
6185-
self.body['server']['availability_zone'] = 'invalid::::zone'
6186-
self.assertRaises(webob.exc.HTTPBadRequest,
6187-
self.controller.create,
6188-
self.req, body=self.body)
6189-
6190-
def test_create_instance_invalid_availability_zone_as_int(self):
6191-
self.body['server']['availability_zone'] = 123
6192-
self.assertRaises(exception.ValidationError,
6193-
self.controller.create,
6194-
self.req, body=self.body)
6195-
61966307
@mock.patch.object(compute_api.API, 'create',
61976308
side_effect=exception.FixedIpNotFoundForAddress(
61986309
address='dummy'))

nova/tests/unit/policies/test_servers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,9 @@ def test_create_server_policy(self, mock_create):
420420

421421
@mock.patch('nova.compute.api.API.create')
422422
@mock.patch('nova.compute.api.API.parse_availability_zone')
423+
@mock.patch.object(
424+
servers.ServersController, '_validate_host_availability_zone',
425+
new=mock.Mock(return_value=None))
423426
def test_create_forced_host_server_policy(self, mock_az, mock_create):
424427
# 'create' policy is checked before 'create:forced_host' so
425428
# we have to allow it for everyone otherwise it will

0 commit comments

Comments
 (0)