diff --git a/ironicclient/tests/unit/v1/test_node.py b/ironicclient/tests/unit/v1/test_node.py index 02e13a1..a0792d9 100644 --- a/ironicclient/tests/unit/v1/test_node.py +++ b/ironicclient/tests/unit/v1/test_node.py @@ -26,6 +26,7 @@ from ironicclient import exc from ironicclient.tests.unit import utils from ironicclient.v1 import node from ironicclient.v1 import volume_connector +from ironicclient.v1 import volume_target if six.PY3: import io @@ -64,6 +65,14 @@ CONNECTOR = {'uuid': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'type': 'iqn', 'connector_id': 'iqn.2010-10.org.openstack:test', 'extra': {}} +TARGET = {'uuid': 'cccccccc-dddd-eeee-ffff-000000000000', + 'node_uuid': 'dddddddd-eeee-ffff-0000-111111111111', + 'volume_type': 'iscsi', + 'properties': {'target_iqn': 'iqn.foo'}, + 'boot_index': 0, + 'volume_id': '12345678', + 'extra': {}} + POWER_STATE = {'power_state': 'power on', 'target_power_state': 'power off'} @@ -316,6 +325,26 @@ fake_responses = { {}, {"connectors": [CONNECTOR]}, ), + }, '/v1/nodes/%s/volume/targets' % NODE1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), + }, + '/v1/nodes/%s/volume/targets?detail=True' % NODE1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), + }, + '/v1/nodes/%s/volume/targets?fields=uuid,value' % NODE1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), }, '/v1/nodes/%s/maintenance' % NODE1['uuid']: { @@ -488,6 +517,20 @@ fake_responses_pagination = { {"connectors": [CONNECTOR]}, ), }, + '/v1/nodes/%s/volume/targets?limit=1' % NODE1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), + }, + '/v1/nodes/%s/volume/targets?marker=%s' % (NODE1['uuid'], TARGET['uuid']): + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), + }, } fake_responses_sorting = { @@ -547,6 +590,20 @@ fake_responses_sorting = { {"connectors": [CONNECTOR]}, ), }, + '/v1/nodes/%s/volume/targets?sort_key=updated_at' % NODE1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), + }, + '/v1/nodes/%s/volume/targets?sort_dir=desc' % NODE1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET]}, + ), + }, } @@ -998,6 +1055,91 @@ class NodeManagerTest(testtools.TestCase): self.mgr.list_volume_connectors, NODE1['uuid'], detail=True, fields=['uuid', 'extra']) + def _validate_node_volume_target_list(self, expect, volume_targets): + self.assertEqual(expect, self.api.calls) + self.assertEqual(1, len(volume_targets)) + self.assertIsInstance(volume_targets[0], + volume_target.VolumeTarget) + self.assertEqual(TARGET['uuid'], volume_targets[0].uuid) + self.assertEqual(TARGET['volume_type'], volume_targets[0].volume_type) + self.assertEqual(TARGET['boot_index'], volume_targets[0].boot_index) + self.assertEqual(TARGET['volume_id'], volume_targets[0].volume_id) + self.assertEqual(TARGET['node_uuid'], volume_targets[0].node_uuid) + + def test_node_volume_target_list(self): + volume_targets = self.mgr.list_volume_targets(NODE1['uuid']) + expect = [ + ('GET', '/v1/nodes/%s/volume/targets' % NODE1['uuid'], + {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = node.NodeManager(self.api) + volume_targets = self.mgr.list_volume_targets(NODE1['uuid'], limit=1) + expect = [ + ('GET', '/v1/nodes/%s/volume/targets?limit=1' % NODE1['uuid'], + {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_marker(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = node.NodeManager(self.api) + volume_targets = self.mgr.list_volume_targets( + NODE1['uuid'], marker=TARGET['uuid']) + expect = [ + ('GET', '/v1/nodes/%s/volume/targets?marker=%s' % ( + NODE1['uuid'], TARGET['uuid']), {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_sort_key(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = node.NodeManager(self.api) + volume_targets = self.mgr.list_volume_targets( + NODE1['uuid'], sort_key='updated_at') + expect = [ + ('GET', '/v1/nodes/%s/volume/targets?sort_key=updated_at' % + NODE1['uuid'], {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_sort_dir(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = node.NodeManager(self.api) + volume_targets = self.mgr.list_volume_targets(NODE1['uuid'], + sort_dir='desc') + expect = [ + ('GET', '/v1/nodes/%s/volume/targets?sort_dir=desc' % + NODE1['uuid'], {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_detail(self): + volume_targets = self.mgr.list_volume_targets(NODE1['uuid'], + detail=True) + expect = [ + ('GET', '/v1/nodes/%s/volume/targets?detail=True' % NODE1['uuid'], + {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_fields(self): + volume_targets = self.mgr.list_volume_targets( + NODE1['uuid'], fields=['uuid', 'value']) + expect = [ + ('GET', '/v1/nodes/%s/volume/targets?fields=uuid,value' % + NODE1['uuid'], {}, None), + ] + self._validate_node_volume_target_list(expect, volume_targets) + + def test_node_volume_target_list_detail_and_fields_fail(self): + self.assertRaises(exc.InvalidAttribute, + self.mgr.list_volume_targets, + NODE1['uuid'], detail=True, fields=['uuid', 'extra']) + def test_node_set_maintenance_true(self): maintenance = self.mgr.set_maintenance(NODE1['uuid'], 'true', maint_reason='reason') diff --git a/ironicclient/tests/unit/v1/test_volume_target.py b/ironicclient/tests/unit/v1/test_volume_target.py new file mode 100644 index 0000000..058f6ae --- /dev/null +++ b/ironicclient/tests/unit/v1/test_volume_target.py @@ -0,0 +1,329 @@ +# Copyright 2016 Hitachi, Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy + +import testtools + +from ironicclient import exc +from ironicclient.tests.unit import utils +import ironicclient.v1.port + +NODE_UUID = '55555555-4444-3333-2222-111111111111' +TARGET1 = {'uuid': '11111111-2222-3333-4444-555555555555', + 'node_uuid': NODE_UUID, + 'volume_type': 'iscsi', + 'properties': {'target_iqn': 'iqn.foo'}, + 'boot_index': 0, + 'volume_id': '12345678', + 'extra': {}} + +TARGET2 = {'uuid': '66666666-7777-8888-9999-000000000000', + 'node_uuid': NODE_UUID, + 'volume_type': 'fibre_channel', + 'properties': {'target_wwn': 'foobar'}, + 'boot_index': 1, + 'volume_id': '87654321', + 'extra': {}} + +CREATE_TARGET = copy.deepcopy(TARGET1) +del CREATE_TARGET['uuid'] + +CREATE_TARGET_WITH_UUID = copy.deepcopy(TARGET1) + +UPDATED_TARGET = copy.deepcopy(TARGET1) +NEW_VALUE = '100' +UPDATED_TARGET['boot_index'] = NEW_VALUE + +fake_responses = { + '/v1/volume/targets': + { + 'GET': ( + {}, + {"targets": [TARGET1]}, + ), + 'POST': ( + {}, + TARGET1 + ), + }, + '/v1/volume/targets/?detail=True': + { + 'GET': ( + {}, + {"targets": [TARGET1]}, + ), + }, + '/v1/volume/targets/?fields=uuid,boot_index': + { + 'GET': ( + {}, + {"targets": [TARGET1]}, + ), + }, + '/v1/volume/targets/%s' % TARGET1['uuid']: + { + 'GET': ( + {}, + TARGET1, + ), + 'DELETE': ( + {}, + None, + ), + 'PATCH': ( + {}, + UPDATED_TARGET, + ), + }, + '/v1/volume/targets/%s?fields=uuid,boot_index' % TARGET1['uuid']: + { + 'GET': ( + {}, + TARGET1, + ), + }, + '/v1/volume/targets/?detail=True&node=%s' % NODE_UUID: + { + 'GET': ( + {}, + {"targets": [TARGET1]}, + ), + }, + '/v1/volume/targets/?node=%s' % NODE_UUID: + { + 'GET': ( + {}, + {"targets": [TARGET1]}, + ), + } +} + +fake_responses_pagination = { + '/v1/volume/targets': + { + 'GET': ( + {}, + {"targets": [TARGET1], + "next": "http://127.0.0.1:6385/v1/volume/targets/?limit=1"} + ), + }, + '/v1/volume/targets/?limit=1': + { + 'GET': ( + {}, + {"targets": [TARGET2]} + ), + }, + '/v1/volume/targets/?marker=%s' % TARGET1['uuid']: + { + 'GET': ( + {}, + {"targets": [TARGET2]} + ), + }, +} + +fake_responses_sorting = { + '/v1/volume/targets/?sort_key=updated_at': + { + 'GET': ( + {}, + {"targets": [TARGET2, TARGET1]} + ), + }, + '/v1/volume/targets/?sort_dir=desc': + { + 'GET': ( + {}, + {"targets": [TARGET2, TARGET1]} + ), + }, +} + + +class VolumeTargetManagerTest(testtools.TestCase): + + def setUp(self): + super(VolumeTargetManagerTest, self).setUp() + self.api = utils.FakeAPI(fake_responses) + self.mgr = ironicclient.v1.volume_target.VolumeTargetManager(self.api) + + def _validate_obj(self, expect, obj): + self.assertEqual(expect['uuid'], obj.uuid) + self.assertEqual(expect['volume_type'], obj.volume_type) + self.assertEqual(expect['boot_index'], obj.boot_index) + self.assertEqual(expect['volume_id'], obj.volume_id) + self.assertEqual(expect['node_uuid'], obj.node_uuid) + + def _validate_list(self, expect_request, + expect_targets, actual_targets): + self.assertEqual(expect_request, self.api.calls) + self.assertEqual(len(expect_targets), len(actual_targets)) + for expect, obj in zip(expect_targets, actual_targets): + self._validate_obj(expect, obj) + + def test_volume_targets_list(self): + volume_targets = self.mgr.list() + expect = [ + ('GET', '/v1/volume/targets', {}, None), + ] + expect_targets = [TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_by_node(self): + volume_targets = self.mgr.list(node=NODE_UUID) + expect = [ + ('GET', '/v1/volume/targets/?node=%s' % NODE_UUID, {}, None), + ] + expect_targets = [TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_by_node_detail(self): + volume_targets = self.mgr.list(node=NODE_UUID, detail=True) + expect = [ + ('GET', '/v1/volume/targets/?detail=True&node=%s' % NODE_UUID, + {}, None), + ] + expect_targets = [TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_detail(self): + volume_targets = self.mgr.list(detail=True) + expect = [ + ('GET', '/v1/volume/targets/?detail=True', {}, None), + ] + expect_targets = [TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_target_list_fields(self): + volume_targets = self.mgr.list(fields=['uuid', 'boot_index']) + expect = [ + ('GET', '/v1/volume/targets/?fields=uuid,boot_index', {}, None), + ] + expect_targets = [TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_target_list_detail_and_fields_fail(self): + self.assertRaises(exc.InvalidAttribute, self.mgr.list, + detail=True, fields=['uuid', 'boot_index']) + + def test_volume_targets_list_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = ironicclient.v1.volume_target.VolumeTargetManager(self.api) + volume_targets = self.mgr.list(limit=1) + expect = [ + ('GET', '/v1/volume/targets/?limit=1', {}, None), + ] + expect_targets = [TARGET2] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_marker(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = ironicclient.v1.volume_target.VolumeTargetManager(self.api) + volume_targets = self.mgr.list(marker=TARGET1['uuid']) + expect = [ + ('GET', '/v1/volume/targets/?marker=%s' % TARGET1['uuid'], + {}, None), + ] + expect_targets = [TARGET2] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_pagination_no_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = ironicclient.v1.volume_target.VolumeTargetManager(self.api) + volume_targets = self.mgr.list(limit=0) + expect = [ + ('GET', '/v1/volume/targets', {}, None), + ('GET', '/v1/volume/targets/?limit=1', {}, None) + ] + expect_targets = [TARGET1, TARGET2] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_sort_key(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = ironicclient.v1.volume_target.VolumeTargetManager(self.api) + volume_targets = self.mgr.list(sort_key='updated_at') + expect = [ + ('GET', '/v1/volume/targets/?sort_key=updated_at', {}, None) + ] + expect_targets = [TARGET2, TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_list_sort_dir(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = ironicclient.v1.volume_target.VolumeTargetManager(self.api) + volume_targets = self.mgr.list(sort_dir='desc') + expect = [ + ('GET', '/v1/volume/targets/?sort_dir=desc', {}, None) + ] + expect_targets = [TARGET2, TARGET1] + self._validate_list(expect, expect_targets, volume_targets) + + def test_volume_targets_show(self): + volume_target = self.mgr.get(TARGET1['uuid']) + expect = [ + ('GET', '/v1/volume/targets/%s' % TARGET1['uuid'], {}, None), + ] + self.assertEqual(expect, self.api.calls) + self._validate_obj(TARGET1, volume_target) + + def test_volume_target_show_fields(self): + volume_target = self.mgr.get(TARGET1['uuid'], + fields=['uuid', 'boot_index']) + expect = [ + ('GET', '/v1/volume/targets/%s?fields=uuid,boot_index' % + TARGET1['uuid'], {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(TARGET1['uuid'], volume_target.uuid) + self.assertEqual(TARGET1['boot_index'], volume_target.boot_index) + + def test_create(self): + volume_target = self.mgr.create(**CREATE_TARGET) + expect = [ + ('POST', '/v1/volume/targets', {}, CREATE_TARGET), + ] + self.assertEqual(expect, self.api.calls) + self._validate_obj(TARGET1, volume_target) + + def test_create_with_uuid(self): + volume_target = self.mgr.create(**CREATE_TARGET_WITH_UUID) + expect = [ + ('POST', '/v1/volume/targets', {}, CREATE_TARGET_WITH_UUID), + ] + self.assertEqual(expect, self.api.calls) + self._validate_obj(TARGET1, volume_target) + + def test_delete(self): + volume_target = self.mgr.delete(TARGET1['uuid']) + expect = [ + ('DELETE', '/v1/volume/targets/%s' % TARGET1['uuid'], + {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertIsNone(volume_target) + + def test_update(self): + patch = {'op': 'replace', + 'value': NEW_VALUE, + 'path': '/boot_index'} + volume_target = self.mgr.update( + volume_target_id=TARGET1['uuid'], patch=patch) + expect = [ + ('PATCH', '/v1/volume/targets/%s' % TARGET1['uuid'], + {}, patch), + ] + self.assertEqual(expect, self.api.calls) + self._validate_obj(UPDATED_TARGET, volume_target) diff --git a/ironicclient/v1/client.py b/ironicclient/v1/client.py index eb8d24d..377e97f 100644 --- a/ironicclient/v1/client.py +++ b/ironicclient/v1/client.py @@ -24,6 +24,7 @@ from ironicclient.v1 import node from ironicclient.v1 import port from ironicclient.v1 import portgroup from ironicclient.v1 import volume_connector +from ironicclient.v1 import volume_target class Client(object): @@ -65,5 +66,7 @@ class Client(object): self.port = port.PortManager(self.http_client) self.volume_connector = volume_connector.VolumeConnectorManager( self.http_client) + self.volume_target = volume_target.VolumeTargetManager( + self.http_client) self.driver = driver.DriverManager(self.http_client) self.portgroup = portgroup.PortgroupManager(self.http_client) diff --git a/ironicclient/v1/node.py b/ironicclient/v1/node.py index a4f6eef..61910d5 100644 --- a/ironicclient/v1/node.py +++ b/ironicclient/v1/node.py @@ -23,6 +23,7 @@ from ironicclient.common.i18n import _ from ironicclient.common import utils from ironicclient import exc from ironicclient.v1 import volume_connector +from ironicclient.v1 import volume_target _power_states = { 'on': 'power on', @@ -251,6 +252,62 @@ class NodeManager(base.CreateManager): self._path(path), response_key="connectors", limit=limit, obj_class=volume_connector.VolumeConnector) + def list_volume_targets(self, node_id, marker=None, limit=None, + sort_key=None, sort_dir=None, detail=False, + fields=None): + """List all the volume targets for a given node. + + :param node_id: Name or UUID of the node. + :param marker: Optional, the UUID of a volume target, eg the last + volume target from a previous result set. Return + the next result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of volume targets to return. + 2) limit == 0, return the entire list of volume targets. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the Ironic API + (see Ironic's api.max_limit option). + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param detail: Optional, boolean whether to return detailed information + about volume targets. + + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. Can not be used + when 'detail' is set. + + :returns: A list of volume targets. + + """ + if limit is not None: + limit = int(limit) + + if detail and fields: + raise exc.InvalidAttribute(_("Can't fetch a subset of fields " + "with 'detail' set")) + + filters = utils.common_filters(marker=marker, limit=limit, + sort_key=sort_key, sort_dir=sort_dir, + fields=fields, detail=detail) + + path = "%s/volume/targets" % node_id + if filters: + path += '?' + '&'.join(filters) + + if limit is None: + return self._list(self._path(path), response_key="targets", + obj_class=volume_target.VolumeTarget) + else: + return self._list_pagination( + self._path(path), response_key="targets", limit=limit, + obj_class=volume_target.VolumeTarget) + def get(self, node_id, fields=None): return self._get(resource_id=node_id, fields=fields) diff --git a/ironicclient/v1/volume_target.py b/ironicclient/v1/volume_target.py new file mode 100644 index 0000000..7a7bf50 --- /dev/null +++ b/ironicclient/v1/volume_target.py @@ -0,0 +1,96 @@ +# Copyright 2016 Hitachi, Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from ironicclient.common import base +from ironicclient.common.i18n import _ +from ironicclient.common import utils +from ironicclient import exc + + +class VolumeTarget(base.Resource): + def __repr__(self): + return "" % self._info + + +class VolumeTargetManager(base.CreateManager): + resource_class = VolumeTarget + _creation_attributes = ['extra', 'node_uuid', 'volume_type', + 'properties', 'boot_index', 'volume_id', + 'uuid'] + _resource_name = 'volume/targets' + + def list(self, node=None, limit=None, marker=None, sort_key=None, + sort_dir=None, detail=False, fields=None): + """Retrieve a list of volume target. + + :param node: Optional, UUID or name of a node, to get volume + targets for this node only. + :param marker: Optional, the UUID of a volume target, eg the last + volume target from a previous result set. Return + the next result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of volume targets to return. + 2) limit == 0, return the entire list of volume targets. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the Ironic API + (see Ironic's api.max_limit option). + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param detail: Optional, boolean whether to return detailed information + about volume targets. + + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. Can not be used + when 'detail' is set. + + :returns: A list of volume targets. + + """ + if limit is not None: + limit = int(limit) + + if detail and fields: + raise exc.InvalidAttribute(_("Can't fetch a subset of fields " + "with 'detail' set")) + + filters = utils.common_filters(marker=marker, limit=limit, + sort_key=sort_key, sort_dir=sort_dir, + fields=fields, detail=detail) + if node is not None: + filters.append('node=%s' % node) + + path = '' + if filters: + path += '?' + '&'.join(filters) + + if limit is None: + return self._list(self._path(path), "targets") + else: + return self._list_pagination(self._path(path), "targets", + limit=limit) + + def get(self, volume_target_id, fields=None): + return self._get(resource_id=volume_target_id, fields=fields) + + def delete(self, volume_target_id): + return self._delete(resource_id=volume_target_id) + + def update(self, volume_target_id, patch): + return self._update(resource_id=volume_target_id, patch=patch) diff --git a/releasenotes/notes/add-volume-target-api-e062303f4b3b40ef.yaml b/releasenotes/notes/add-volume-target-api-e062303f4b3b40ef.yaml new file mode 100644 index 0000000..a597c01 --- /dev/null +++ b/releasenotes/notes/add-volume-target-api-e062303f4b3b40ef.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + Adds these python API client methods to support volume target resources + (available starting with API version 1.32): + + * ``client.volume_target.create`` for creating a volume target + * ``client.volume_target.list`` for listing volume targets + * ``client.volume_target.get`` for getting a volume target + * ``client.volume_target.update`` for updating a volume target + * ``client.volume_target.delete`` for deleting a volume target + * ``client.node.list_volume_targets`` for getting volume targets + associated with a node