Add unpin AZ functionality

This allows a user to remove the availability-zone pinning from a
server, as well as re-pin a server to the AZ it is currently in.

Related to blueprint unpin-az

Generated-By: Claude Opus 4.6
Change-Id: I4278f6c43814ca620662b44cc4950467324211e2
Signed-off-by: Dan Smith <dansmith@redhat.com>
This commit is contained in:
Dan Smith committed 2026-06-12 10:08:55 -07:00
1 parent a968a73a8f
commit 4bb7256f60
13 files changed
+401 -5

No files matched your search

@@ -0,0 +1,9 @@
{
"server": {
"accessIPv4": "1.2.3.4",
"accessIPv6": "80fe::",
"OS-DCF:diskConfig": "AUTO",
"hostname": "new-server-hostname.example.com",
"pinned_availability_zone": null
}
}
@@ -0,0 +1,84 @@
{
"server": {
"accessIPv4": "1.2.3.4",
"accessIPv6": "80fe::",
"addresses": {
"private": [
{
"addr": "192.168.1.30",
"OS-EXT-IPS-MAC:mac_addr": "00:0c:29:0d:11:74",
"OS-EXT-IPS:type": "fixed",
"version": 4
}
]
},
"created": "2013-09-03T04:01:32Z",
"description": null,
"locked": false,
"locked_reason": null,
"flavor": {
"disk": 1,
"ephemeral": 0,
"extra_specs": {},
"original_name": "m1.tiny",
"ram": 512,
"swap": 0,
"vcpus": 1
},
"hostId": "92154fab69d5883ba2c8622b7e65f745dd33257221c07af363c51b29",
"id": "0e44cc9c-e052-415d-afbf-469b0d384170",
"image": {
"id": "70a599e0-31e7-49b7-b260-868f441e862b",
"links": [
{
"href": "http://openstack.example.com/6f70656e737461636b20342065766572/images/70a599e0-31e7-49b7-b260-868f441e862b",
"rel": "bookmark"
}
],
"properties": {
"architecture": "x86_64",
"auto_disk_config": "True"
}
},
"key_name": null,
"links": [
{
"href": "http://openstack.example.com/v2/6f70656e737461636b20342065766572/servers/0e44cc9c-e052-415d-afbf-469b0d384170",
"rel": "self"
},
{
"href": "http://openstack.example.com/6f70656e737461636b20342065766572/servers/0e44cc9c-e052-415d-afbf-469b0d384170",
"rel": "bookmark"
}
],
"metadata": {
"My Server Name": "Apache1"
},
"name": "new-server-test",
"config_drive": "",
"OS-DCF:diskConfig": "AUTO",
"OS-EXT-AZ:availability_zone": "nova",
"OS-EXT-SRV-ATTR:hostname": "new-server-hostname.example.com",
"OS-EXT-STS:power_state": 1,
"OS-EXT-STS:task_state": null,
"OS-EXT-STS:vm_state": "active",
"os-extended-volumes:volumes_attached": [],
"OS-SRV-USG:launched_at": "2013-09-23T13:37:00.880302",
"OS-SRV-USG:terminated_at": null,
"pinned_availability_zone": null,
"progress": 0,
"scheduler_hints": {},
"security_groups": [
{
"name": "default"
}
],
"server_groups": [],
"status": "ACTIVE",
"tags": [],
"tenant_id": "6f70656e737461636b20342065766572",
"trusted_image_certificates": null,
"updated": "2013-09-03T04:01:33Z",
"user_id": "fake"
}
}
@@ -19,7 +19,7 @@
}
],
"status": "CURRENT",
"version": "2.103",
"version": "2.104",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}
@@ -22,7 +22,7 @@
}
],
"status": "CURRENT",
"version": "2.103",
"version": "2.104",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}
+4 -1
View File
@@ -286,6 +286,9 @@ REST_API_VERSION_HISTORY = """REST API Version History:
filters from various flavors APIs and restrict additional query
string parameters for all APIs.
* 2.103 - Remove the ``/os-volumes_boot`` API
* 2.104 - Add support for updating ``pinned_availability_zone`` via
``PUT /servers/{server_id}`` to unpin or re-pin a server's
availability zone.
"""
# The minimum and maximum versions of the API supported
@@ -294,7 +297,7 @@ REST_API_VERSION_HISTORY = """REST API Version History:
# Note(cyeoh): This only applies for the v2.1 API once microversions
# support is fully merged. It does not affect the V2 API.
_MIN_API_VERSION = '2.1'
_MAX_API_VERSION = '2.103'
_MAX_API_VERSION = '2.104'
DEFAULT_API_VERSION = _MIN_API_VERSION
# Almost all proxy APIs which are related to network, images and baremetal
@@ -1335,3 +1335,25 @@ microversion 2.75.
The ``/os-volumes_boot`` API is an old alias for the ``/servers`` API and was
undocumented and untested. It has now been removed and will return HTTP 404 for
all requests.
.. _microversion 2.104:
2.104
-----
Add support for updating the ``pinned_availability_zone`` field via
``PUT /servers/{server_id}``. Setting ``pinned_availability_zone`` to ``null``
will unpin the server from its availability zone, allowing it to be scheduled
to any availability zone on the next move operation. Setting
``pinned_availability_zone`` to the server's current
``OS-EXT-AZ:availability_zone`` value will re-pin the server to that zone.
The following transitions will return an HTTP 409 (Conflict) error:
- Attempting to re-pin (set to a non-``null`` value) to an availability zone
that does not match the server's current location
- Attempting to change from one non-``null`` availability zone to a different
non-``null`` value (unpin first, then re-pin)
Setting ``pinned_availability_zone`` to its current value is idempotent and
returns 200.
@@ -401,6 +401,10 @@ update_v294 = copy.deepcopy(update_v290)
update_v294['properties']['server'][
'properties']['hostname'] = parameter_types.fqdn
update_v2104 = copy.deepcopy(update_v294)
update_v2104['properties']['server'][
'properties']['pinned_availability_zone'] = parameter_types.name_or_none
rebuild = {
'type': 'object',
'properties': {
+40 -2
View File
@@ -31,6 +31,7 @@ from nova.api.openstack.compute.schemas import servers as schema
from nova.api.openstack.compute.views import servers as views_servers
from nova.api.openstack import wsgi
from nova.api import validation
from nova import availability_zones as avail_zone
from nova import block_device
from nova.compute import api as compute
from nova.compute import flavors
@@ -952,12 +953,13 @@ class ServersController(wsgi.Controller):
else:
self.compute_api.delete(context, instance)
@wsgi.expected_errors(404)
@wsgi.expected_errors((404, 409))
@validation.schema(schema.update_v20, '2.0', '2.0')
@validation.schema(schema.update, '2.1', '2.18')
@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.schema(schema.update_v294, '2.94', '2.103')
@validation.schema(schema.update_v2104, '2.104')
@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
@@ -993,6 +995,42 @@ class ServersController(wsgi.Controller):
if 'hostname' in server:
update_dict['hostname'] = server['hostname']
if (api_version_request.is_supported(req, '2.104') and
'pinned_availability_zone' in server):
requested_az = server['pinned_availability_zone']
request_spec = objects.RequestSpec.get_by_instance_uuid(
ctxt, instance.uuid)
current_pinned_az = request_spec.availability_zone
if requested_az is None:
if current_pinned_az is not None:
request_spec.availability_zone = None
request_spec.save()
else:
instance_az = (
avail_zone.get_instance_availability_zone(
ctxt, instance))
if current_pinned_az is not None:
if requested_az != current_pinned_az:
raise exc.HTTPConflict(
explanation=_(
"Cannot change pinned availability "
"zone directly. Unpin first by "
"setting pinned_availability_zone "
"to null."))
elif requested_az != instance_az:
raise exc.HTTPConflict(
explanation=_(
"Server can only be pinned to its "
"current availability zone "
"'%(current_az)s', but "
"'%(requested_az)s' was requested."
) % {'current_az': instance_az,
'requested_az': requested_az})
else:
request_spec.availability_zone = requested_az
request_spec.save()
helpers.translate_attributes(helpers.UPDATE, server, update_dict)
try:
@@ -0,0 +1,9 @@
{
"server": {
"accessIPv4": "%(access_ip_v4)s",
"accessIPv6": "%(access_ip_v6)s",
"OS-DCF:diskConfig": "AUTO",
"hostname": "new-server-hostname.example.com",
"pinned_availability_zone": null
}
}
@@ -0,0 +1,84 @@
{
"server": {
"accessIPv4": "%(access_ip_v4)s",
"accessIPv6": "%(access_ip_v6)s",
"addresses": {
"private": [
{
"addr": "%(ip)s",
"OS-EXT-IPS-MAC:mac_addr": "00:0c:29:0d:11:74",
"OS-EXT-IPS:type": "fixed",
"version": 4
}
]
},
"created": "%(isotime)s",
"description": null,
"locked": false,
"locked_reason": null,
"flavor": {
"disk": 1,
"ephemeral": 0,
"extra_specs": {},
"original_name": "m1.tiny",
"ram": 512,
"swap": 0,
"vcpus": 1
},
"hostId": "%(hostid)s",
"id": "%(id)s",
"image": {
"id": "%(uuid)s",
"links": [
{
"href": "%(compute_endpoint)s/images/%(uuid)s",
"rel": "bookmark"
}
],
"properties": {
"architecture": "x86_64",
"auto_disk_config": "True"
}
},
"key_name": null,
"links": [
{
"href": "%(versioned_compute_endpoint)s/servers/%(uuid)s",
"rel": "self"
},
{
"href": "%(compute_endpoint)s/servers/%(uuid)s",
"rel": "bookmark"
}
],
"metadata": {
"My Server Name": "Apache1"
},
"name": "new-server-test",
"config_drive": "",
"OS-DCF:diskConfig": "AUTO",
"OS-EXT-AZ:availability_zone": "nova",
"OS-EXT-SRV-ATTR:hostname": "new-server-hostname.example.com",
"OS-EXT-STS:power_state": 1,
"OS-EXT-STS:task_state": null,
"OS-EXT-STS:vm_state": "active",
"os-extended-volumes:volumes_attached": [],
"OS-SRV-USG:launched_at": "%(strtime)s",
"OS-SRV-USG:terminated_at": null,
"pinned_availability_zone": null,
"progress": 0,
"scheduler_hints": {},
"security_groups": [
{
"name": "default"
}
],
"server_groups": [],
"status": "ACTIVE",
"tags": [],
"tenant_id": "6f70656e737461636b20342065766572",
"trusted_image_certificates": null,
"updated": "%(isotime)s",
"user_id": "fake"
}
}
@@ -868,6 +868,22 @@ class ServersUpdateSampleJson294Test(ServersUpdateSampleJsonTest):
self._verify_response('server-action-rebuild-resp', subs, resp, 202)
class ServersUpdateSampleJson2104Test(ServersUpdateSampleJsonTest):
microversion = '2.104'
scenarios = [('v2_104', {'api_major_version': 'v2.1'})]
ADMIN_API = False
def test_update_server(self):
uuid = self._post_server()
subs = {}
subs['hostid'] = '[a-f0-9]+'
subs['access_ip_v4'] = '1.2.3.4'
subs['access_ip_v6'] = '80fe::'
response = self._do_put('servers/%s' % uuid,
'server-update-req', subs)
self._verify_response('server-update-resp', subs, response, 200)
class ServerSortKeysJsonTests(ServersSampleBase):
sample_dir = 'servers-sort'
@@ -4259,6 +4259,124 @@ class ServersControllerUpdateTestV290(_ServersControllerUpdateTest):
self.assertIn('hostname', str(ex))
class ServersControllerUpdateTestV2104(_ServersControllerUpdateTest):
microversion = '2.104'
def _setup_reqspec_mock(self, availability_zone):
mock_reqspec = mock.MagicMock()
mock_reqspec.availability_zone = availability_zone
objects.RequestSpec.get_by_instance_uuid.return_value = mock_reqspec
return mock_reqspec
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_unpin_az(self, mock_get_az):
mock_reqspec = self._setup_reqspec_mock('az1')
body = {'server': {'pinned_availability_zone': None}}
req = self._get_request(body)
res_dict = self.controller.update(req, FAKE_UUID, body=body)
self.assertEqual(FAKE_UUID, res_dict['server']['id'])
self.assertIsNone(mock_reqspec.availability_zone)
mock_reqspec.save.assert_called_once()
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_repin_az(self, mock_get_az):
mock_reqspec = self._setup_reqspec_mock(None)
body = {'server': {'pinned_availability_zone': 'az1'}}
req = self._get_request(body)
res_dict = self.controller.update(req, FAKE_UUID, body=body)
self.assertEqual(FAKE_UUID, res_dict['server']['id'])
self.assertEqual('az1', mock_reqspec.availability_zone)
mock_reqspec.save.assert_called_once()
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_unpin_az_already_unpinned(self, mock_get_az):
mock_reqspec = self._setup_reqspec_mock(None)
body = {'server': {'pinned_availability_zone': None}}
req = self._get_request(body)
res_dict = self.controller.update(req, FAKE_UUID, body=body)
self.assertEqual(FAKE_UUID, res_dict['server']['id'])
mock_reqspec.save.assert_not_called()
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_repin_az_already_pinned(self, mock_get_az):
mock_reqspec = self._setup_reqspec_mock('az1')
body = {'server': {'pinned_availability_zone': 'az1'}}
req = self._get_request(body)
self.controller.update(req, FAKE_UUID, body=body)
mock_reqspec.save.assert_not_called()
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_repin_az_wrong_az(self, mock_get_az):
self._setup_reqspec_mock(None)
body = {'server': {'pinned_availability_zone': 'az2'}}
req = self._get_request(body)
self.assertRaises(
webob.exc.HTTPConflict,
self.controller.update,
req, FAKE_UUID, body=body)
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_repin_az_change_pinned(self, mock_get_az):
self._setup_reqspec_mock('az1')
body = {'server': {'pinned_availability_zone': 'az2'}}
req = self._get_request(body)
self.assertRaises(
webob.exc.HTTPConflict,
self.controller.update,
req, FAKE_UUID, body=body)
def test_update_server_pinned_az_old_version(self):
body = {'server': {'pinned_availability_zone': None}}
req = self._get_request(body)
req.api_version_request = api_version_request.APIVersionRequest(
'2.103')
self.assertRaises(
exception.ValidationError,
self.controller.update,
req, FAKE_UUID, body=body)
def test_update_server_pinned_az_empty_string(self):
body = {'server': {'pinned_availability_zone': ''}}
req = self._get_request(body)
self.assertRaises(
exception.ValidationError,
self.controller.update,
req, FAKE_UUID, body=body)
@mock.patch('nova.availability_zones.get_instance_availability_zone',
return_value='az1')
def test_update_server_pinned_az_with_name(self, mock_get_az):
mock_reqspec = self._setup_reqspec_mock('az1')
body = {'server': {
'name': 'new-name',
'pinned_availability_zone': None,
}}
req = self._get_request(body)
res_dict = self.controller.update(req, FAKE_UUID, body=body)
self.assertEqual(FAKE_UUID, res_dict['server']['id'])
self.assertEqual('new-name', res_dict['server']['name'])
self.assertIsNone(mock_reqspec.availability_zone)
mock_reqspec.save.assert_called_once()
class ServersControllerTriggerCrashDumpTest(ControllerTest):
def setUp(self):
@@ -0,0 +1,9 @@
---
features:
- |
A new microversion has been added to allow unpinning and re-pinning the
availability zone for an instance. By doing a ``PUT /servers/{server_id}``
containing a value for ``pinned_availability_zone``, a user can remove the
AZ or add it back. For re-pinning, only the current AZ of the instance is
allowed to be set (i.e. equal to the ``OS-EXT-AZ:availability_zone``)
value.