diff options
-rw-r--r-- | api-ref/source/baremetal-api-v1-node-management.inc | 4 | ||||
-rw-r--r-- | api-ref/source/parameters.yaml | 16 | ||||
-rw-r--r-- | ironic/api/controllers/v1/node.py | 9 | ||||
-rw-r--r-- | ironic/api/controllers/v1/utils.py | 50 | ||||
-rw-r--r-- | ironic/api/controllers/v1/versions.py | 5 | ||||
-rw-r--r-- | ironic/common/release_mappings.py | 2 | ||||
-rw-r--r-- | ironic/conductor/manager.py | 2 | ||||
-rw-r--r-- | ironic/conductor/utils.py | 29 | ||||
-rw-r--r-- | ironic/tests/unit/api/controllers/v1/test_node.py | 36 | ||||
-rw-r--r-- | ironic/tests/unit/api/controllers/v1/test_utils.py | 40 | ||||
-rw-r--r-- | ironic/tests/unit/conductor/test_manager.py | 63 | ||||
-rw-r--r-- | lower-constraints.txt | 2 | ||||
-rw-r--r-- | releasenotes/notes/build-configdrive-5b3b9095824faf4e.yaml | 9 | ||||
-rw-r--r-- | requirements.txt | 1 |
14 files changed, 241 insertions, 27 deletions
diff --git a/api-ref/source/baremetal-api-v1-node-management.inc b/api-ref/source/baremetal-api-v1-node-management.inc index fd6f15059..4cc58ce05 100644 --- a/api-ref/source/baremetal-api-v1-node-management.inc +++ b/api-ref/source/baremetal-api-v1-node-management.inc @@ -352,6 +352,10 @@ detailed documentation of the Ironic State Machine is available A node can be rescued or unrescued by setting the node's provision target state to ``rescue`` or ``unrescue`` respectively. +.. versionadded:: 1.56 + A ``configdrive`` can be a JSON object with ``meta_data``, ``network_data`` + and ``user_data``. + Normal response code: 202 Error codes: diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 0e0c1eb08..c562a14aa 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -545,12 +545,20 @@ conductor_group: type: string configdrive: description: | - A gzip'ed and base-64 encoded config drive, to be written to a partition - on the Node's boot disk. This parameter is only accepted when setting the - state to "active" or "rebuild". + A config drive to be written to a partition on the Node's boot disk. Can be + a full gzip'ed and base-64 encoded image or a JSON object with the keys: + + * ``meta_data`` (optional) - JSON object with the standard meta data. + Ironic will provide the defaults for the ``uuid`` and ``name`` fields. + * ``network_data`` (optional) - JSON object with networking configuration. + * ``user_data`` (optional) - user data. May be a string (which will be + UTF-8 encoded); a JSON object, or a JSON array. + + This parameter is only accepted when setting the state to "active" or + "rebuild". in: body required: false - type: string or gzip+b64 blob + type: string or object console_enabled: description: | Indicates whether console access is enabled or disabled on this node. diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 41fddd606..fe40634c4 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -600,7 +600,7 @@ class NodeStatesController(rest.RestController): @METRICS.timer('NodeStatesController.provision') @expose.expose(None, types.uuid_or_name, wtypes.text, - wtypes.text, types.jsontype, wtypes.text, + types.jsontype, types.jsontype, wtypes.text, status_code=http_client.ACCEPTED) def provision(self, node_ident, target, configdrive=None, clean_steps=None, rescue_password=None): @@ -616,8 +616,8 @@ class NodeStatesController(rest.RestController): :param node_ident: UUID or logical name of a node. :param target: The desired provision state of the node or verb. :param configdrive: Optional. A gzipped and base64 encoded - configdrive. Only valid when setting provision state - to "active" or "rebuild". + configdrive or a dict to build a configdrive from. Only valid when + setting provision state to "active" or "rebuild". :param clean_steps: An ordered list of cleaning steps that will be performed on the node. A cleaning step is a dictionary with required keys 'interface' and 'step', and optional key 'args'. If @@ -681,8 +681,7 @@ class NodeStatesController(rest.RestController): action=target, node=rpc_node.uuid, state=rpc_node.provision_state) - if configdrive: - api_utils.check_allow_configdrive(target) + api_utils.check_allow_configdrive(target, configdrive) if clean_steps and target != ir_states.VERBS['clean']: msg = (_('"clean_steps" is only valid when setting target ' diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index f75cf5617..5123be509 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -17,6 +17,8 @@ import inspect import re import jsonpatch +import jsonschema +from jsonschema import exceptions as json_schema_exc import os_traits from oslo_config import cfg from oslo_utils import uuidutils @@ -586,7 +588,30 @@ def check_allow_driver_detail(detail): 'opr': versions.MINOR_30_DYNAMIC_DRIVERS}) -def check_allow_configdrive(target): +_CONFIG_DRIVE_SCHEMA = { + 'anyOf': [ + { + 'type': 'object', + 'properties': { + 'meta_data': {'type': 'object'}, + 'network_data': {'type': 'object'}, + 'user_data': { + 'type': ['object', 'array', 'string', 'null'] + } + }, + 'additionalProperties': False + }, + { + 'type': ['string', 'null'] + } + ] +} + + +def check_allow_configdrive(target, configdrive=None): + if not configdrive: + return + allowed_targets = [states.ACTIVE] if allow_node_rebuild_with_configdrive(): allowed_targets.append(states.REBUILD) @@ -597,6 +622,21 @@ def check_allow_configdrive(target): raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) + try: + jsonschema.validate(configdrive, _CONFIG_DRIVE_SCHEMA) + except json_schema_exc.ValidationError as e: + msg = _('Invalid configdrive format: %s') % e + raise wsme.exc.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + + if isinstance(configdrive, dict) and not allow_build_configdrive(): + msg = _('Providing a JSON object for configdrive is only supported' + ' starting with API version %(base)s.%(opr)s') % { + 'base': versions.BASE_VERSION, + 'opr': versions.MINOR_56_BUILD_CONFIGDRIVE} + raise wsme.exc.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + def check_allow_filter_by_fault(fault): """Check if filtering nodes by fault is allowed. @@ -1094,3 +1134,11 @@ def check_policy(policy_name): """ cdict = pecan.request.context.to_policy_values() policy.authorize(policy_name, cdict, cdict) + + +def allow_build_configdrive(): + """Check if building configdrive is allowed. + + Version 1.56 of the API added support for building configdrive. + """ + return pecan.request.version.minor >= versions.MINOR_56_BUILD_CONFIGDRIVE diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 5298d789c..2a52437cd 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -92,6 +92,8 @@ BASE_VERSION = 1 # v1.52: Add allocation API. # v1.53: Add support for Smart NIC port # v1.54: Add events support. +# v1.55: Add deploy templates API. +# v1.56: Add support for building configdrives. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -149,6 +151,7 @@ MINOR_52_ALLOCATION = 52 MINOR_53_PORT_SMARTNIC = 53 MINOR_54_EVENTS = 54 MINOR_55_DEPLOY_TEMPLATES = 55 +MINOR_56_BUILD_CONFIGDRIVE = 56 # When adding another version, update: # - MINOR_MAX_VERSION @@ -156,7 +159,7 @@ MINOR_55_DEPLOY_TEMPLATES = 55 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_55_DEPLOY_TEMPLATES +MINOR_MAX_VERSION = MINOR_56_BUILD_CONFIGDRIVE # 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 73ff0b683..725a19bb5 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -131,7 +131,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.55', + 'api': '1.56', 'rpc': '1.48', 'objects': { 'Allocation': ['1.0'], diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index cca182625..bd2f021f4 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -3623,6 +3623,8 @@ def do_node_deploy(task, conductor_id=None, configdrive=None): try: if configdrive: + if isinstance(configdrive, dict): + configdrive = utils.build_configdrive(node, configdrive) _store_configdrive(node, configdrive) except (exception.SwiftOperationError, exception.ConfigInvalid) as e: with excutils.save_and_reraise_exception(): diff --git a/ironic/conductor/utils.py b/ironic/conductor/utils.py index d8eeb4326..b780dc6d6 100644 --- a/ironic/conductor/utils.py +++ b/ironic/conductor/utils.py @@ -15,8 +15,10 @@ import collections import time +from openstack.baremetal import configdrive as os_configdrive from oslo_config import cfg from oslo_log import log +from oslo_serialization import jsonutils from oslo_service import loopingcall from oslo_utils import excutils import six @@ -1316,3 +1318,30 @@ def validate_deploy_templates(task): # Gather deploy steps from matching deploy templates, validate them. user_steps = _get_steps_from_deployment_templates(task) _validate_user_deploy_steps(task, user_steps) + + +def build_configdrive(node, configdrive): + """Build a configdrive from provided meta_data, network_data and user_data. + + If uuid or name are not provided in the meta_data, they're defauled to the + node's uuid and name accordingly. + + :param node: an Ironic node object. + :param configdrive: A configdrive as a dict with keys ``meta_data``, + ``network_data`` and ``user_data`` (all optional). + :returns: A gzipped and base64 encoded configdrive as a string. + """ + meta_data = configdrive.setdefault('meta_data', {}) + meta_data.setdefault('uuid', node.uuid) + if node.name: + meta_data.setdefault('name', node.name) + + user_data = configdrive.get('user_data') + if isinstance(user_data, (dict, list)): + user_data = jsonutils.dump_as_bytes(user_data) + elif user_data: + user_data = user_data.encode('utf-8') + + LOG.debug('Building a configdrive for node %s', node.uuid) + return os_configdrive.build(meta_data, user_data=user_data, + network_data=configdrive.get('network_data')) diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index c76c11e69..7ce332b1f 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -4121,6 +4121,42 @@ class TestPut(test_api_base.BaseApiTest): self.assertEqual(urlparse.urlparse(ret.location).path, expected_location) + def test_provision_with_deploy_configdrive_as_dict(self): + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.ACTIVE, + 'configdrive': {'user_data': 'foo'}}, + headers={api_base.Version.string: '1.56'}) + self.assertEqual(http_client.ACCEPTED, ret.status_code) + self.assertEqual(b'', ret.body) + self.mock_dnd.assert_called_once_with(context=mock.ANY, + node_id=self.node.uuid, + rebuild=False, + configdrive={'user_data': 'foo'}, + topic='test-topic') + + def test_provision_with_deploy_configdrive_as_dict_all_fields(self): + fake_cd = {'user_data': {'serialize': 'me'}, + 'meta_data': {'hostname': 'example.com'}, + 'network_data': {'links': []}} + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.ACTIVE, + 'configdrive': fake_cd}, + headers={api_base.Version.string: '1.56'}) + self.assertEqual(http_client.ACCEPTED, ret.status_code) + self.assertEqual(b'', ret.body) + self.mock_dnd.assert_called_once_with(context=mock.ANY, + node_id=self.node.uuid, + rebuild=False, + configdrive=fake_cd, + topic='test-topic') + + def test_provision_with_deploy_configdrive_as_dict_unsupported(self): + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.ACTIVE, + 'configdrive': {'user_data': 'foo'}}, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + def test_provision_with_rebuild(self): node = self.node node.provision_state = states.ACTIVE diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py index d456ea10f..1feed997a 100644 --- a/ironic/tests/unit/api/controllers/v1/test_utils.py +++ b/ironic/tests/unit/api/controllers/v1/test_utils.py @@ -501,18 +501,48 @@ class TestApiUtils(base.TestCase): def test_check_allow_configdrive_fails(self, mock_request): mock_request.version.minor = 35 self.assertRaises(wsme.exc.ClientSideError, - utils.check_allow_configdrive, states.DELETED) + utils.check_allow_configdrive, states.DELETED, + "abcd") + self.assertRaises(wsme.exc.ClientSideError, + utils.check_allow_configdrive, states.ACTIVE, + {'meta_data': {}}) mock_request.version.minor = 34 self.assertRaises(wsme.exc.ClientSideError, - utils.check_allow_configdrive, states.REBUILD) + utils.check_allow_configdrive, states.REBUILD, + "abcd") @mock.patch.object(pecan, 'request', spec_set=['version']) def test_check_allow_configdrive(self, mock_request): mock_request.version.minor = 35 - utils.check_allow_configdrive(states.ACTIVE) - utils.check_allow_configdrive(states.REBUILD) + utils.check_allow_configdrive(states.ACTIVE, "abcd") + utils.check_allow_configdrive(states.REBUILD, "abcd") mock_request.version.minor = 34 - utils.check_allow_configdrive(states.ACTIVE) + utils.check_allow_configdrive(states.ACTIVE, "abcd") + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_configdrive_as_dict(self, mock_request): + mock_request.version.minor = 56 + utils.check_allow_configdrive(states.ACTIVE, {'meta_data': {}}) + utils.check_allow_configdrive(states.ACTIVE, {'meta_data': {}, + 'network_data': {}, + 'user_data': {}}) + utils.check_allow_configdrive(states.ACTIVE, {'user_data': 'foo'}) + utils.check_allow_configdrive(states.ACTIVE, {'user_data': ['foo']}) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_configdrive_as_dict_invalid(self, mock_request): + mock_request.version.minor = 56 + self.assertRaises(wsme.exc.ClientSideError, + utils.check_allow_configdrive, states.REBUILD, + {'foo': 'bar'}) + for key in ['meta_data', 'network_data']: + self.assertRaises(wsme.exc.ClientSideError, + utils.check_allow_configdrive, states.REBUILD, + {key: 'a string'}) + for key in ['meta_data', 'network_data', 'user_data']: + self.assertRaises(wsme.exc.ClientSideError, + utils.check_allow_configdrive, states.REBUILD, + {key: 42}) @mock.patch.object(pecan, 'request', spec_set=['version']) def test_allow_rescue_interface(self, mock_request): diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index e63d1cbc6..77c8cae47 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -2035,26 +2035,29 @@ class DoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): mock_store.assert_called_once_with(task.node, configdrive) @mock.patch.object(manager, '_store_configdrive') - def _test__do_node_deploy_ok(self, mock_store, configdrive=None): + def _test__do_node_deploy_ok(self, mock_store, configdrive=None, + expected_configdrive=None): + expected_configdrive = expected_configdrive or configdrive self._start_service() with mock.patch.object(fake.FakeDeploy, 'deploy', autospec=True) as mock_deploy: mock_deploy.return_value = None - node = obj_utils.create_test_node( - self.context, driver='fake-hardware', + self.node = obj_utils.create_test_node( + self.context, driver='fake-hardware', name=None, provision_state=states.DEPLOYING, target_provision_state=states.ACTIVE) - task = task_manager.TaskManager(self.context, node.uuid) + task = task_manager.TaskManager(self.context, self.node.uuid) manager.do_node_deploy(task, self.service.conductor.id, configdrive=configdrive) - node.refresh() - self.assertEqual(states.ACTIVE, node.provision_state) - self.assertEqual(states.NOSTATE, node.target_provision_state) - self.assertIsNone(node.last_error) + self.node.refresh() + self.assertEqual(states.ACTIVE, self.node.provision_state) + self.assertEqual(states.NOSTATE, self.node.target_provision_state) + self.assertIsNone(self.node.last_error) mock_deploy.assert_called_once_with(mock.ANY, mock.ANY) if configdrive: - mock_store.assert_called_once_with(task.node, configdrive) + mock_store.assert_called_once_with(task.node, + expected_configdrive) else: self.assertFalse(mock_store.called) @@ -2065,6 +2068,48 @@ class DoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): configdrive = 'foo' self._test__do_node_deploy_ok(configdrive=configdrive) + @mock.patch('openstack.baremetal.configdrive.build', autospec=True) + def test__do_node_deploy_configdrive_as_dict(self, mock_cd): + mock_cd.return_value = 'foo' + configdrive = {'user_data': 'abcd'} + self._test__do_node_deploy_ok(configdrive=configdrive, + expected_configdrive='foo') + mock_cd.assert_called_once_with({'uuid': self.node.uuid}, + network_data=None, + user_data=b'abcd') + + @mock.patch('openstack.baremetal.configdrive.build', autospec=True) + def test__do_node_deploy_configdrive_as_dict_with_meta_data(self, mock_cd): + mock_cd.return_value = 'foo' + configdrive = {'meta_data': {'uuid': uuidutils.generate_uuid(), + 'name': 'new-name', + 'hostname': 'example.com'}} + self._test__do_node_deploy_ok(configdrive=configdrive, + expected_configdrive='foo') + mock_cd.assert_called_once_with(configdrive['meta_data'], + network_data=None, + user_data=None) + + @mock.patch('openstack.baremetal.configdrive.build', autospec=True) + def test__do_node_deploy_configdrive_with_network_data(self, mock_cd): + mock_cd.return_value = 'foo' + configdrive = {'network_data': {'links': []}} + self._test__do_node_deploy_ok(configdrive=configdrive, + expected_configdrive='foo') + mock_cd.assert_called_once_with({'uuid': self.node.uuid}, + network_data={'links': []}, + user_data=None) + + @mock.patch('openstack.baremetal.configdrive.build', autospec=True) + def test__do_node_deploy_configdrive_and_user_data_as_dict(self, mock_cd): + mock_cd.return_value = 'foo' + configdrive = {'user_data': {'user': 'data'}} + self._test__do_node_deploy_ok(configdrive=configdrive, + expected_configdrive='foo') + mock_cd.assert_called_once_with({'uuid': self.node.uuid}, + network_data=None, + user_data=b'{"user": "data"}') + @mock.patch.object(swift, 'SwiftAPI') @mock.patch('ironic.drivers.modules.fake.FakeDeploy.prepare') def test__do_node_deploy_configdrive_swift_error(self, mock_prepare, diff --git a/lower-constraints.txt b/lower-constraints.txt index aedaa737f..effe77bc6 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -61,7 +61,7 @@ munch==2.2.0 netaddr==0.7.19 netifaces==0.10.6 openstackdocstheme==1.18.1 -openstacksdk==0.12.0 +openstacksdk==0.25.0 os-api-ref==1.4.0 os-client-config==1.29.0 os-service-types==1.2.0 diff --git a/releasenotes/notes/build-configdrive-5b3b9095824faf4e.yaml b/releasenotes/notes/build-configdrive-5b3b9095824faf4e.yaml new file mode 100644 index 000000000..9cb2e13ca --- /dev/null +++ b/releasenotes/notes/build-configdrive-5b3b9095824faf4e.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Adds support for building config drives. Starting with API version 1.56, + the ``configdrive`` parameter of ``/v1/nodes/<node>/states/provision`` can + be a JSON object with optional keys ``meta_data`` (JSON object), + ``network_data`` (JSON object) and ``user_data`` (JSON object, array or + string). See `story 2005083 + <https://storyboard.openstack.org/#!/story/2005083>`_ for more details. diff --git a/requirements.txt b/requirements.txt index 544a2a75b..ba1845d8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,3 +47,4 @@ jsonschema<3.0.0,>=2.6.0 # MIT psutil>=3.2.2 # BSD futurist>=1.2.0 # Apache-2.0 tooz>=1.58.0 # Apache-2.0 +openstacksdk>=0.25.0 # Apache-2.0 |