summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ironic/api/controllers/v1/node.py27
-rw-r--r--ironic/api/controllers/v1/utils.py19
-rw-r--r--ironic/api/controllers/v1/versions.py4
-rw-r--r--ironic/common/release_mappings.py2
-rw-r--r--ironic/db/sqlalchemy/api.py5
-rw-r--r--ironic/tests/unit/api/controllers/v1/test_node.py75
-rw-r--r--ironic/tests/unit/api/controllers/v1/test_utils.py22
-rw-r--r--ironic/tests/unit/db/test_nodes.py20
-rw-r--r--releasenotes/notes/conductor-groups-c22c17e276e63bed.yaml17
9 files changed, 168 insertions, 23 deletions
diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py
index fc91a9475..1808a4f94 100644
--- a/ironic/api/controllers/v1/node.py
+++ b/ironic/api/controllers/v1/node.py
@@ -1249,7 +1249,7 @@ class Node(base.APIBase):
management_interface=None, power_interface=None,
raid_interface=None, vendor_interface=None,
storage_interface=None, traits=[], rescue_interface=None,
- bios_interface=None)
+ bios_interface=None, conductor_group="")
# NOTE(matty_dubs): The chassis_uuid getter() is based on the
# _chassis_uuid variable:
sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12'
@@ -1552,7 +1552,8 @@ class NodesController(rest.RestController):
maintenance, provision_state, marker, limit,
sort_key, sort_dir, driver=None,
resource_class=None, resource_url=None,
- fields=None, fault=None, detail=None):
+ fields=None, fault=None, conductor_group=None,
+ detail=None):
if self.from_chassis and not chassis_uuid:
raise exception.MissingParameterValue(
_("Chassis id not specified."))
@@ -1600,6 +1601,8 @@ class NodesController(rest.RestController):
filters['resource_class'] = resource_class
if fault is not None:
filters['fault'] = fault
+ if conductor_group is not None:
+ filters['conductor_group'] = conductor_group
nodes = objects.Node.list(pecan.request.context, limit, marker_obj,
sort_key=sort_key, sort_dir=sort_dir,
@@ -1707,11 +1710,12 @@ class NodesController(rest.RestController):
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
wtypes.text, wtypes.text, types.listtype, wtypes.text,
- wtypes.text, types.boolean)
+ wtypes.text, wtypes.text, types.boolean)
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None,
- fields=None, resource_class=None, fault=None, detail=None):
+ fields=None, resource_class=None, fault=None,
+ conductor_group=None, detail=None):
"""Retrieve a list of nodes.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@@ -1737,6 +1741,8 @@ class NodesController(rest.RestController):
driver.
:param resource_class: Optional string value to get only nodes with
that resource_class.
+ :param conductor_group: Optional string value to get only nodes with
+ that conductor_group.
:param fields: Optional, a list with a specified set of fields
of the resource to be returned.
:param fault: Optional string value to get only nodes with that fault.
@@ -1751,6 +1757,7 @@ class NodesController(rest.RestController):
api_utils.check_allow_specify_driver(driver)
api_utils.check_allow_specify_resource_class(resource_class)
api_utils.check_allow_filter_by_fault(fault)
+ api_utils.check_allow_filter_by_conductor_group(conductor_group)
fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS)
@@ -1762,16 +1769,18 @@ class NodesController(rest.RestController):
driver=driver,
resource_class=resource_class,
fields=fields, fault=fault,
+ conductor_group=conductor_group,
detail=detail)
@METRICS.timer('NodesController.detail')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
- wtypes.text, wtypes.text, wtypes.text, wtypes.text)
+ wtypes.text, wtypes.text, wtypes.text, wtypes.text,
+ wtypes.text)
def detail(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None,
- resource_class=None, fault=None):
+ resource_class=None, fault=None, conductor_group=None):
"""Retrieve a list of nodes with detail.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@@ -1798,6 +1807,8 @@ class NodesController(rest.RestController):
:param resource_class: Optional string value to get only nodes with
that resource_class.
:param fault: Optional string value to get only nodes with that fault.
+ :param conductor_group: Optional string value to get only nodes with
+ that conductor_group.
"""
cdict = pecan.request.context.to_policy_values()
policy.authorize('baremetal:node:get', cdict, cdict)
@@ -1806,6 +1817,7 @@ class NodesController(rest.RestController):
api_utils.check_allow_specify_driver(driver)
api_utils.check_allow_specify_resource_class(resource_class)
api_utils.check_allow_filter_by_fault(fault)
+ api_utils.check_allow_filter_by_conductor_group(conductor_group)
api_utils.check_allowed_fields([sort_key])
# /detail should only work against collections
parent = pecan.request.path.split('/')[:-1][-1]
@@ -1820,7 +1832,8 @@ class NodesController(rest.RestController):
driver=driver,
resource_class=resource_class,
resource_url=resource_url,
- fault=fault)
+ fault=fault,
+ conductor_group=conductor_group)
@METRICS.timer('NodesController.validate')
@expose.expose(wtypes.text, types.uuid_or_name, types.uuid)
diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py
index 56c2db697..ff9980f8b 100644
--- a/ironic/api/controllers/v1/utils.py
+++ b/ironic/api/controllers/v1/utils.py
@@ -502,6 +502,20 @@ def check_allow_filter_by_fault(fault):
msg, status_code=http_client.BAD_REQUEST)
+def check_allow_filter_by_conductor_group(conductor_group):
+ """Check if filtering nodes by conductor_group is allowed.
+
+ Version 1.46 of the API allows filtering nodes by conductor_group.
+ """
+ if (conductor_group is not None and pecan.request.version.minor
+ < versions.MINOR_46_NODE_CONDUCTOR_GROUP):
+ raise exception.NotAcceptable(_(
+ "Request not acceptable. The minimal required API version "
+ "should be %(base)s.%(opr)s") %
+ {'base': versions.BASE_VERSION,
+ 'opr': versions.MINOR_46_NODE_CONDUCTOR_GROUP})
+
+
def initial_node_provision_state():
"""Return node state to use by default when creating new nodes.
@@ -873,9 +887,10 @@ def allow_reset_interfaces():
def allow_conductor_group():
"""Check if passing a conductor_group for a node is allowed.
- There is no version yet that allows this.
+ Version 1.46 exposes this field.
"""
- return False
+ return (pecan.request.version.minor >=
+ versions.MINOR_46_NODE_CONDUCTOR_GROUP)
def get_request_return_fields(fields, detail, default_fields):
diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py
index b0d336f76..650e8be78 100644
--- a/ironic/api/controllers/v1/versions.py
+++ b/ironic/api/controllers/v1/versions.py
@@ -83,6 +83,7 @@ BASE_VERSION = 1
# v1.43: Add detail=True flag to all API endpoints
# v1.44: Add node deploy_step field
# v1.45: reset_interfaces parameter to node's PATCH
+# v1.46: Add conductor_group to the node object.
MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1
@@ -130,6 +131,7 @@ MINOR_42_FAULT = 42
MINOR_43_ENABLE_DETAIL_QUERY = 43
MINOR_44_NODE_DEPLOY_STEP = 44
MINOR_45_RESET_INTERFACES = 45
+MINOR_46_NODE_CONDUCTOR_GROUP = 46
# When adding another version, update:
# - MINOR_MAX_VERSION
@@ -137,7 +139,7 @@ MINOR_45_RESET_INTERFACES = 45
# explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
-MINOR_MAX_VERSION = MINOR_45_RESET_INTERFACES
+MINOR_MAX_VERSION = MINOR_46_NODE_CONDUCTOR_GROUP
# String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py
index 81712818c..855702b42 100644
--- a/ironic/common/release_mappings.py
+++ b/ironic/common/release_mappings.py
@@ -115,7 +115,7 @@ RELEASE_MAPPING = {
}
},
'master': {
- 'api': '1.45',
+ 'api': '1.46',
'rpc': '1.47',
'objects': {
'Node': ['1.26', '1.27'],
diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py
index 9e3c9918c..c7fcaf1f7 100644
--- a/ironic/db/sqlalchemy/api.py
+++ b/ironic/db/sqlalchemy/api.py
@@ -223,7 +223,8 @@ class Connection(api.Connection):
'resource_class', 'provision_state', 'uuid', 'id',
'chassis_uuid', 'associated', 'reserved',
'reserved_by_any_of', 'provisioned_before',
- 'inspection_started_before', 'fault'}
+ 'inspection_started_before', 'fault',
+ 'conductor_group'}
unsupported_filters = set(filters).difference(supported_filters)
if unsupported_filters:
msg = _("SqlAlchemy API does not support "
@@ -231,7 +232,7 @@ class Connection(api.Connection):
raise ValueError(msg)
for field in ['console_enabled', 'maintenance', 'driver',
'resource_class', 'provision_state', 'uuid', 'id',
- 'fault']:
+ 'fault', 'conductor_group']:
if field in filters:
query = query.filter_by(**{field: filters[field]})
if 'chassis_uuid' in filters:
diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py
index 992f91bb6..26a047bf7 100644
--- a/ironic/tests/unit/api/controllers/v1/test_node.py
+++ b/ironic/tests/unit/api/controllers/v1/test_node.py
@@ -123,6 +123,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('chassis_id', data['nodes'][0])
self.assertNotIn('bios_interface', data['nodes'][0])
self.assertNotIn('deploy_step', data['nodes'][0])
+ self.assertNotIn('conductor_group', data['nodes'][0])
def test_get_one(self):
node = obj_utils.create_test_node(self.context,
@@ -160,6 +161,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('chassis_id', data)
self.assertIn('bios_interface', data)
self.assertIn('deploy_step', data)
+ self.assertIn('conductor_group', data)
def test_get_one_with_json(self):
# Test backward compatibility with guess_content_type_from_ext
@@ -257,11 +259,8 @@ class TestListNodes(test_api_base.BaseApiTest):
'1.43', '1.44')
def test_node_conductor_group_hidden_in_lower_version(self):
- node = obj_utils.create_test_node(self.context)
- data = self.get_json(
- '/nodes/%s' % node.uuid,
- headers={api_base.Version.string: '1.44'})
- self.assertNotIn('conductor_group', data)
+ self._test_node_field_hidden_in_lower_version('conductor_group',
+ '1.45', '1.46')
def test_get_one_custom_fields(self):
node = obj_utils.create_test_node(self.context,
@@ -406,10 +405,19 @@ class TestListNodes(test_api_base.BaseApiTest):
fields = 'conductor_group'
response = self.get_json(
'/nodes/%s?fields=%s' % (node.uuid, fields),
- headers={api_base.Version.string: '1.43'},
+ headers={api_base.Version.string: '1.44'},
expect_errors=True)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
+ def test_get_conductor_group_fields(self):
+ node = obj_utils.create_test_node(self.context,
+ chassis_id=self.chassis.id)
+ fields = 'conductor_group'
+ response = self.get_json(
+ '/nodes/%s?fields=%s' % (node.uuid, fields),
+ headers={api_base.Version.string: '1.46'})
+ self.assertIn('conductor_group', response)
+
def test_detail(self):
node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)
@@ -439,6 +447,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn(field, data['nodes'][0])
self.assertIn('storage_interface', data['nodes'][0])
self.assertIn('traits', data['nodes'][0])
+ self.assertIn('conductor_group', data['nodes'][0])
# never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0])
@@ -467,6 +476,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('target_raid_config', data['nodes'][0])
self.assertIn('network_interface', data['nodes'][0])
self.assertIn('resource_class', data['nodes'][0])
+ self.assertIn('conductor_group', data['nodes'][0])
for field in api_utils.V31_FIELDS:
self.assertIn(field, data['nodes'][0])
# never expose the chassis_id
@@ -1411,6 +1421,36 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
self.assertTrue(response.json['error_message'])
+ def test_get_nodes_by_conductor_group(self):
+ node1 = obj_utils.create_test_node(self.context,
+ uuid=uuidutils.generate_uuid(),
+ conductor_group='group1')
+ node2 = obj_utils.create_test_node(self.context,
+ uuid=uuidutils.generate_uuid(),
+ conductor_group='group2')
+
+ for base_url in ('/nodes', '/nodes/detail'):
+ data = self.get_json(base_url + '?conductor_group=group1',
+ headers={api_base.Version.string: "1.46"})
+ uuids = [n['uuid'] for n in data['nodes']]
+ self.assertIn(node1.uuid, uuids)
+ self.assertNotIn(node2.uuid, uuids)
+ data = self.get_json(base_url + '?conductor_group=group2',
+ headers={api_base.Version.string: "1.46"})
+ uuids = [n['uuid'] for n in data['nodes']]
+ self.assertIn(node2.uuid, uuids)
+ self.assertNotIn(node1.uuid, uuids)
+
+ def test_get_nodes_by_conductor_group_not_allowed(self):
+ for url in ('/nodes?conductor_group=group1',
+ '/nodes/detail?conductor_group=group1'):
+ response = self.get_json(
+ url, headers={api_base.Version.string: "1.44"},
+ expect_errors=True)
+ self.assertEqual('application/json', response.content_type)
+ self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
+ self.assertTrue(response.json['error_message'])
+
def test_get_console_information(self):
node = obj_utils.create_test_node(self.context)
expected_console_info = {'test': 'test-data'}
@@ -2491,6 +2531,19 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertEqual(http_client.BAD_REQUEST, response.status_code)
self.assertTrue(response.json['error_message'])
+ def test_update_conductor_group(self):
+ node = obj_utils.create_test_node(self.context,
+ uuid=uuidutils.generate_uuid())
+ self.mock_update_node.return_value = node
+ headers = {api_base.Version.string: '1.46'}
+ response = self.patch_json('/nodes/%s' % node.uuid,
+ [{'path': '/conductor_group',
+ 'value': 'foogroup',
+ 'op': 'add'}],
+ headers=headers)
+ self.assertEqual('application/json', response.content_type)
+ self.assertEqual(http_client.OK, response.status_code)
+
def test_update_conductor_group_old_api(self):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid())
@@ -2728,8 +2781,16 @@ class TestPost(test_api_base.BaseApiTest):
# Check that 'id' is not in first arg of positional args
self.assertNotIn('id', cn_mock.call_args[0][0])
+ def test_create_node_specify_conductor_group(self):
+ headers = {api_base.Version.string: '1.46'}
+ ndict = test_api_utils.post_get_test_node(conductor_group='foo')
+ self.post_json('/nodes', ndict, headers=headers)
+
+ result = self.get_json('/nodes/%s' % ndict['uuid'], headers=headers)
+ self.assertEqual('foo', result['conductor_group'])
+
def test_create_node_specify_conductor_group_bad_version(self):
- headers = {api_base.Version.string: '1.43'}
+ headers = {api_base.Version.string: '1.44'}
ndict = test_api_utils.post_get_test_node(conductor_group='foo')
response = self.post_json('/nodes', ndict, headers=headers,
expect_errors=True)
diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py
index e2b236e84..af2a3fe09 100644
--- a/ironic/tests/unit/api/controllers/v1/test_utils.py
+++ b/ironic/tests/unit/api/controllers/v1/test_utils.py
@@ -270,6 +270,22 @@ class TestApiUtils(base.TestCase):
utils.check_allow_filter_driver_type, 'classic')
@mock.patch.object(pecan, 'request', spec_set=['version'])
+ def test_check_allow_filter_by_conductor_group(self, mock_request):
+ mock_request.version.minor = 46
+ self.assertIsNone(utils.check_allow_filter_by_conductor_group('foo'))
+
+ @mock.patch.object(pecan, 'request', spec_set=['version'])
+ def test_check_allow_filter_by_conductor_group_none(self, mock_request):
+ mock_request.version.minor = 46
+ self.assertIsNone(utils.check_allow_filter_by_conductor_group(None))
+
+ @mock.patch.object(pecan, 'request', spec_set=['version'])
+ def test_check_allow_filter_by_conductor_group_fail(self, mock_request):
+ mock_request.version.minor = 44
+ self.assertRaises(exception.NotAcceptable,
+ utils.check_allow_filter_by_conductor_group, 'foo')
+
+ @mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_driver_detail(self, mock_request):
mock_request.version.minor = 30
self.assertIsNone(utils.check_allow_driver_detail(True))
@@ -521,7 +537,11 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 40
self.assertFalse(utils.allow_inspect_abort())
- def test_allow_conductor_group(self):
+ @mock.patch.object(pecan, 'request', spec_set=['version'])
+ def test_allow_conductor_group(self, mock_request):
+ mock_request.version.minor = 46
+ self.assertTrue(utils.allow_conductor_group())
+ mock_request.version.minor = 45
self.assertFalse(utils.allow_conductor_group())
diff --git a/ironic/tests/unit/db/test_nodes.py b/ironic/tests/unit/db/test_nodes.py
index 590b22420..45b770370 100644
--- a/ironic/tests/unit/db/test_nodes.py
+++ b/ironic/tests/unit/db/test_nodes.py
@@ -144,7 +144,8 @@ class DbNodeTestCase(base.DbTestCase):
uuid=uuidutils.generate_uuid(),
maintenance=True,
fault='boom',
- resource_class='foo')
+ resource_class='foo',
+ conductor_group='group1')
node3 = utils.create_test_node(
driver='driver-one',
uuid=uuidutils.generate_uuid(),
@@ -188,6 +189,14 @@ class DbNodeTestCase(base.DbTestCase):
self.assertEqual([node2.id], [r.id for r in res])
res = self.dbapi.get_nodeinfo_list(
+ filters={'conductor_group': 'group1'})
+ self.assertEqual([node2.id], [r.id for r in res])
+
+ res = self.dbapi.get_nodeinfo_list(
+ filters={'conductor_group': 'group2'})
+ self.assertEqual([], [r.id for r in res])
+
+ res = self.dbapi.get_nodeinfo_list(
filters={'reserved_by_any_of': ['fake-host',
'another-fake-host']})
self.assertEqual(sorted([node1.id, node3.id]),
@@ -292,7 +301,8 @@ class DbNodeTestCase(base.DbTestCase):
chassis_id=ch2['id'],
maintenance=True,
fault='boom',
- resource_class='foo')
+ resource_class='foo',
+ conductor_group='group1')
res = self.dbapi.get_node_list(filters={'chassis_uuid': ch1['uuid']})
self.assertEqual([node1.id], [r.id for r in res])
@@ -333,6 +343,12 @@ class DbNodeTestCase(base.DbTestCase):
res = self.dbapi.get_node_list(filters={'resource_class': 'foo'})
self.assertEqual([node2.id], [r.id for r in res])
+ res = self.dbapi.get_node_list(filters={'conductor_group': 'group1'})
+ self.assertEqual([node2.id], [r.id for r in res])
+
+ res = self.dbapi.get_node_list(filters={'conductor_group': 'group2'})
+ self.assertEqual([], [r.id for r in res])
+
res = self.dbapi.get_node_list(filters={'id': node1.id})
self.assertEqual([node1.id], [r.id for r in res])
diff --git a/releasenotes/notes/conductor-groups-c22c17e276e63bed.yaml b/releasenotes/notes/conductor-groups-c22c17e276e63bed.yaml
new file mode 100644
index 000000000..8a1857e2f
--- /dev/null
+++ b/releasenotes/notes/conductor-groups-c22c17e276e63bed.yaml
@@ -0,0 +1,17 @@
+---
+features:
+ - |
+ Conductors and nodes may be arbitrarily grouped to provide a basic level
+ of affinity between conductors and nodes. Conductors use the
+ ``[conductor]/conductor_group`` configuration option to set the group
+ which they belong to. The same value may be set on one or more nodes
+ in the ``conductor_group`` field (available in API version 1.46), and
+ these will be matched such that only conductors with a given group will
+ manage nodes with the same group.
+
+ A group name may be up to 255 characters containing ``a-z``, ``0-9``,
+ ``_``, ``-``, and ``.``. The group is case-insensitive. The default group
+ is the empty string (``""``).
+
+ The "node list" API endpoint (``GET /v1/nodes``) may also be filtered by
+ conductor group in API version 1.46.