Skip to content

Commit 5878240

Browse files
committed
api: Align availability zone info with forced host
Users can create a server like so: $ openstack server create --availability-zone az:host ... This is a historical way to request that an instance be scheduled to a specific host and it causes the scheduler to be bypassed. However, no validation of this availability zone-host combo takes place. The host could in fact belong to a different availability zone. If it does, we'll end up in a very odd situation whereby the RequestSpec record for the instance will record the availability zone requested by the user at create time, but the Instance record itself will record the availability zone of the host on which the instance was scheduled. This leads to even more confusing behavior when we attempt to do something like live migrate the instance since the RequestSpec record, with its original and possibly invalid availability zone information, is used. The 'AvailabilityZoneFilter' will fail an error message like the following: Availability Zone 'foo' requested. ... has AZs: bar but the 'openstack server list --long' command will show a non-foo value for the availability zone column. The solution is simple: when given an availability zone-host combo, make sure the availability zone requested matches that of the host (or, more specifically, the host is a member of the host aggregates that form the availability zone [1]). If not, simply ignore the requested availability zone information in favour of using the availability zone of the host, logging a warning just for record keeping purposes. This is deemed preferable to failing with HTTP 400 (Bad Request) since what users are really requesting by using this was to schedule to a specific host: the availability zone portion of the request is really irrelevant and just an artifact of this legacy mechanism to request hosts. If users wish to truly validate a host-availability zone combo, they can use the 'host' field introduced in microversion 2.74 along with the 'availability_zone' field: $ openstack server create --availability-zone az --host host ... Conflicts: nova/compute/api.py NOTE(stephenfin): Conflicts are trivial and due to the absence of change I81fec10535034f3a81d46713a6eda813f90561cf ("Remove references to 'instance_type'") which we don't want to backport here. [1] https://docs.openstack.org/nova/latest/admin/aggregates.html Change-Id: Iac0e634e66cd4e150a50935cf635f626fc11b70e Signed-off-by: Stephen Finucane <[email protected]> Closes-Bug: #1934770 (cherry picked from commit 8f21ee4)
1 parent b0099aa commit 5878240

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)