From 172d1b22df0a86b63730379fe2a41553ed2b53c5 Mon Sep 17 00:00:00 2001
From: Dmitry Tantsur <dtantsur@protonmail.com>
Date: Mon, 10 May 2021 16:50:48 +0200
Subject: [PATCH] Delay rendering configdrive

When the configdrive input is JSON (meta_data, etc), delay the rendering
until the ISO image is actually used. It has two benefits:
1) Avoid storing a large ISO image in instance_info,
2) Allow deploy steps to access the original user's input.

Fix configdrive masking to correctly mask dicts.

Story: #2008875
Task: #42419
Change-Id: I86d30bbb505b8c794bfa6412606f4516f8885aa9
---
 ironic/api/controllers/v1/node.py             |   3 +
 ironic/conductor/deployments.py               |   6 +-
 ironic/conductor/utils.py                     |  15 +++
 ironic/drivers/modules/agent.py               |   6 +-
 ironic/drivers/modules/ansible/deploy.py      |   2 +-
 ironic/drivers/modules/redfish/boot.py        |  30 +++---
 ironic/objects/node.py                        |   9 +-
 .../unit/api/controllers/v1/test_node.py      |  19 ++++
 .../tests/unit/conductor/test_deployments.py  |  88 ++++++---------
 ironic/tests/unit/conductor/test_utils.py     |  68 ++++++++++++
 .../drivers/modules/ansible/test_deploy.py    |  30 ++++++
 .../unit/drivers/modules/redfish/test_boot.py | 100 +++++++++++++++---
 .../tests/unit/drivers/modules/test_agent.py  |  67 ++++++++++++
 ironic/tests/unit/objects/test_node.py        |  25 +++++
 .../configdrive-render-8eb398d956393d60.yaml  |   6 ++
 15 files changed, 384 insertions(+), 90 deletions(-)
 create mode 100644 releasenotes/notes/configdrive-render-8eb398d956393d60.yaml

diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py
index 401bd577c6..173d7b8f5a 100644
--- a/ironic/api/controllers/v1/node.py
+++ b/ironic/api/controllers/v1/node.py
@@ -1423,6 +1423,9 @@ def node_sanitize(node, fields):
     if not show_instance_secrets and node.get('instance_info'):
         node['instance_info'] = strutils.mask_dict_password(
             node['instance_info'], "******")
+        # NOTE(dtantsur): configdrive may be a dict
+        if node['instance_info'].get('configdrive'):
+            node['instance_info']['configdrive'] = "******"
         # NOTE(tenbrae): agent driver may store a swift temp_url on the
         # instance_info, which shouldn't be exposed to non-admin users.
         # Now that ironic supports additional policies, we need to hide
diff --git a/ironic/conductor/deployments.py b/ironic/conductor/deployments.py
index e146a26d60..11670c0b2c 100644
--- a/ironic/conductor/deployments.py
+++ b/ironic/conductor/deployments.py
@@ -130,8 +130,6 @@ def do_node_deploy(task, conductor_id=None, configdrive=None,
     utils.wipe_deploy_internal_info(task)
     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():
@@ -417,6 +415,10 @@ def _store_configdrive(node, configdrive):
 
     """
     if CONF.deploy.configdrive_use_object_store:
+        # Don't store the JSON source in swift.
+        if isinstance(configdrive, dict):
+            configdrive = utils.build_configdrive(node, configdrive)
+
         # NOTE(lucasagomes): No reason to use a different timeout than
         # the one used for deploying the node
         timeout = (CONF.conductor.configdrive_swift_temp_url_duration
diff --git a/ironic/conductor/utils.py b/ironic/conductor/utils.py
index 3b8b54c221..2a3e6b4f83 100644
--- a/ironic/conductor/utils.py
+++ b/ironic/conductor/utils.py
@@ -1005,6 +1005,21 @@ def build_configdrive(node, configdrive):
                                 vendor_data=configdrive.get('vendor_data'))
 
 
+def get_configdrive_image(node):
+    """Get configdrive as an ISO image or a URL.
+
+    Converts the JSON representation into an image. URLs and raw contents
+    are returned unchanged.
+
+    :param node: an Ironic node object.
+    :returns: A gzipped and base64 encoded configdrive as a string.
+    """
+    configdrive = node.instance_info.get('configdrive')
+    if isinstance(configdrive, dict):
+        configdrive = build_configdrive(node, configdrive)
+    return configdrive
+
+
 def fast_track_able(task):
     """Checks if the operation can be a streamlined deployment sequence.
 
diff --git a/ironic/drivers/modules/agent.py b/ironic/drivers/modules/agent.py
index 3296fb9347..3007689692 100644
--- a/ironic/drivers/modules/agent.py
+++ b/ironic/drivers/modules/agent.py
@@ -561,7 +561,11 @@ class AgentDeploy(CustomAgentDeploy):
             if disk_label is not None:
                 image_info['disk_label'] = disk_label
 
-        configdrive = node.instance_info.get('configdrive')
+        configdrive = manager_utils.get_configdrive_image(node)
+        if configdrive:
+            # FIXME(dtantsur): remove this duplication once IPA is ready:
+            # https://review.opendev.org/c/openstack/ironic-python-agent/+/790471
+            image_info['configdrive'] = configdrive
         # Now switch into the corresponding in-band deploy step and let the
         # result be polled normally.
         new_step = {'interface': 'deploy',
diff --git a/ironic/drivers/modules/ansible/deploy.py b/ironic/drivers/modules/ansible/deploy.py
index 2c17bccdb3..218d046b2e 100644
--- a/ironic/drivers/modules/ansible/deploy.py
+++ b/ironic/drivers/modules/ansible/deploy.py
@@ -284,7 +284,7 @@ def _prepare_variables(task):
             image['checksum'] = 'md5:%s' % checksum
     _add_ssl_image_options(image)
     variables = {'image': image}
-    configdrive = i_info.get('configdrive')
+    configdrive = manager_utils.get_configdrive_image(task.node)
     if configdrive:
         if urlparse.urlparse(configdrive).scheme in ('http', 'https'):
             cfgdrv_type = 'url'
diff --git a/ironic/drivers/modules/redfish/boot.py b/ironic/drivers/modules/redfish/boot.py
index b854fc994d..b393fa71bb 100644
--- a/ironic/drivers/modules/redfish/boot.py
+++ b/ironic/drivers/modules/redfish/boot.py
@@ -636,21 +636,12 @@ class RedfishVirtualMediaBoot(base.BootInterface):
         managers = redfish_utils.get_system(task.node).managers
 
         deploy_info = _parse_deploy_info(node)
-        configdrive = node.instance_info.get('configdrive')
         iso_ref = image_utils.prepare_boot_iso(task, deploy_info, **params)
         _eject_vmedia(task, managers, sushy.VIRTUAL_MEDIA_CD)
         _insert_vmedia(task, managers, iso_ref, sushy.VIRTUAL_MEDIA_CD)
 
-        if configdrive and boot_option == 'ramdisk':
-            _eject_vmedia(task, managers, sushy.VIRTUAL_MEDIA_USBSTICK)
-            cd_ref = image_utils.prepare_configdrive_image(task, configdrive)
-            try:
-                _insert_vmedia(task, managers, cd_ref,
-                               sushy.VIRTUAL_MEDIA_USBSTICK)
-            except exception.InvalidParameterValue:
-                raise exception.InstanceDeployFailure(
-                    _('Cannot attach configdrive for node %s: no suitable '
-                      'virtual USB slot has been found') % node.uuid)
+        if boot_option == 'ramdisk':
+            self._attach_configdrive(task, managers)
 
         del managers
 
@@ -660,6 +651,21 @@ class RedfishVirtualMediaBoot(base.BootInterface):
                   "%(device)s", {'node': task.node.uuid,
                                  'device': boot_devices.CDROM})
 
+    def _attach_configdrive(self, task, managers):
+        configdrive = manager_utils.get_configdrive_image(task.node)
+        if not configdrive:
+            return
+
+        _eject_vmedia(task, managers, sushy.VIRTUAL_MEDIA_USBSTICK)
+        cd_ref = image_utils.prepare_configdrive_image(task, configdrive)
+        try:
+            _insert_vmedia(task, managers, cd_ref,
+                           sushy.VIRTUAL_MEDIA_USBSTICK)
+        except exception.InvalidParameterValue:
+            raise exception.InstanceDeployFailure(
+                _('Cannot attach configdrive for node %s: no suitable '
+                  'virtual USB slot has been found') % task.node.uuid)
+
     def _eject_all(self, task):
         managers = redfish_utils.get_system(task.node).managers
 
@@ -676,7 +682,7 @@ class RedfishVirtualMediaBoot(base.BootInterface):
 
         boot_option = deploy_utils.get_boot_option(task.node)
         if (boot_option == 'ramdisk'
-                and task.node.instance_info.get('configdrive')):
+                and task.node.instance_info.get('configdrive') is not None):
             _eject_vmedia(task, managers, sushy.VIRTUAL_MEDIA_USBSTICK)
             image_utils.cleanup_disk_image(task, prefix='configdrive')
 
diff --git a/ironic/objects/node.py b/ironic/objects/node.py
index c8f79f2868..3eb997c513 100644
--- a/ironic/objects/node.py
+++ b/ironic/objects/node.py
@@ -174,11 +174,12 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
             d['driver_info'] = strutils.mask_dict_password(
                 d.get('driver_info', {}), "******")
             iinfo = d.pop('instance_info', {})
-            if not mask_configdrive:
-                configdrive = iinfo.pop('configdrive', None)
+            configdrive = iinfo.pop('configdrive', None)
             d['instance_info'] = strutils.mask_dict_password(iinfo, "******")
-            if not mask_configdrive and configdrive:
-                d['instance_info']['configdrive'] = configdrive
+            if configdrive is not None:
+                d['instance_info']['configdrive'] = (
+                    "******" if mask_configdrive else configdrive
+                )
             d['driver_internal_info'] = strutils.mask_dict_password(
                 d.get('driver_internal_info', {}), "******")
         return d
diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py
index cc778cd770..baa21d5a97 100644
--- a/ironic/tests/unit/api/controllers/v1/test_node.py
+++ b/ironic/tests/unit/api/controllers/v1/test_node.py
@@ -186,6 +186,25 @@ class TestListNodes(test_api_base.BaseApiTest):
         self.assertNotIn('allocation_id', data)
         self.assertIn('allocation_uuid', data)
 
+    def test_get_one_configdrive_dict(self):
+        fake_instance_info = {
+            "configdrive": {'user_data': 'data'},
+            "image_url": "http://example.com/test_image_url",
+            "foo": "bar",
+        }
+        node = obj_utils.create_test_node(self.context,
+                                          chassis_id=self.chassis.id,
+                                          instance_info=fake_instance_info)
+        data = self.get_json(
+            '/nodes/%s' % node.uuid,
+            headers={api_base.Version.string: str(api_v1.max_version())})
+        self.assertEqual(node.uuid, data['uuid'])
+        self.assertEqual('******', data['driver_info']['fake_password'])
+        self.assertEqual('bar', data['driver_info']['foo'])
+        self.assertEqual('******', data['instance_info']['configdrive'])
+        self.assertEqual('******', data['instance_info']['image_url'])
+        self.assertEqual('bar', data['instance_info']['foo'])
+
     def test_get_one_with_json(self):
         # Test backward compatibility with guess_content_type_from_ext
         node = obj_utils.create_test_node(self.context,
diff --git a/ironic/tests/unit/conductor/test_deployments.py b/ironic/tests/unit/conductor/test_deployments.py
index f0e8944611..0207359846 100644
--- a/ironic/tests/unit/conductor/test_deployments.py
+++ b/ironic/tests/unit/conductor/test_deployments.py
@@ -174,63 +174,6 @@ class DoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
     def test__do_node_deploy_fast_track(self):
         self._test__do_node_deploy_ok(fast_track=True)
 
-    @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',
-                                        vendor_data=None)
-
-    @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,
-                                        vendor_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,
-                                        vendor_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"}',
-                                        vendor_data=None)
-
-    @mock.patch('openstack.baremetal.configdrive.build', autospec=True)
-    def test__do_node_deploy_configdrive_with_vendor_data(self, mock_cd):
-        mock_cd.return_value = 'foo'
-        configdrive = {'vendor_data': {'foo': 'bar'}}
-        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=None,
-                                        vendor_data={'foo': 'bar'})
-
     @mock.patch.object(swift, 'SwiftAPI', autospec=True)
     @mock.patch('ironic.drivers.modules.fake.FakeDeploy.prepare',
                 autospec=True)
@@ -1014,6 +957,37 @@ class StoreConfigDriveTestCase(db_base.DbTestCase):
         self.node.refresh()
         self.assertEqual(expected_instance_info, self.node.instance_info)
 
+    @mock.patch.object(conductor_utils, 'build_configdrive', autospec=True)
+    def test_store_configdrive_swift_build(self, mock_cd, mock_swift):
+        container_name = 'foo_container'
+        timeout = 123
+        expected_obj_name = 'configdrive-%s' % self.node.uuid
+        expected_obj_header = {'X-Delete-After': str(timeout)}
+        expected_instance_info = {'configdrive': 'http://1.2.3.4'}
+
+        mock_cd.return_value = 'fake'
+
+        # set configs and mocks
+        CONF.set_override('configdrive_use_object_store', True,
+                          group='deploy')
+        CONF.set_override('configdrive_swift_container', container_name,
+                          group='conductor')
+        CONF.set_override('deploy_callback_timeout', timeout,
+                          group='conductor')
+        mock_swift.return_value.get_temp_url.return_value = 'http://1.2.3.4'
+
+        deployments._store_configdrive(self.node, {'meta_data': {}})
+
+        mock_swift.assert_called_once_with()
+        mock_swift.return_value.create_object.assert_called_once_with(
+            container_name, expected_obj_name, mock.ANY,
+            object_headers=expected_obj_header)
+        mock_swift.return_value.get_temp_url.assert_called_once_with(
+            container_name, expected_obj_name, timeout)
+        self.node.refresh()
+        self.assertEqual(expected_instance_info, self.node.instance_info)
+        mock_cd.assert_called_once_with(self.node, {'meta_data': {}})
+
     def test_store_configdrive_swift_no_deploy_timeout(self, mock_swift):
         container_name = 'foo_container'
         expected_obj_name = 'configdrive-%s' % self.node.uuid
diff --git a/ironic/tests/unit/conductor/test_utils.py b/ironic/tests/unit/conductor/test_utils.py
index e8f20e94af..c94aff01c1 100644
--- a/ironic/tests/unit/conductor/test_utils.py
+++ b/ironic/tests/unit/conductor/test_utils.py
@@ -2308,3 +2308,71 @@ class CacheVendorTestCase(db_base.DbTestCase):
         self.node.refresh()
         self.assertNotIn('vendor', self.node.properties)
         self.assertTrue(mock_log.called)
+
+
+class GetConfigDriveImageTestCase(db_base.DbTestCase):
+
+    def setUp(self):
+        super(GetConfigDriveImageTestCase, self).setUp()
+        self.node = obj_utils.create_test_node(
+            self.context,
+            uuid=uuidutils.generate_uuid(),
+            instance_info={})
+
+    def test_no_configdrive(self):
+        self.assertIsNone(conductor_utils.get_configdrive_image(self.node))
+
+    def test_string(self):
+        self.node.instance_info['configdrive'] = 'data'
+        self.assertEqual('data',
+                         conductor_utils.get_configdrive_image(self.node))
+
+    @mock.patch('openstack.baremetal.configdrive.build', autospec=True)
+    def test_build_empty(self, mock_cd):
+        self.node.instance_info['configdrive'] = {}
+        self.assertEqual(mock_cd.return_value,
+                         conductor_utils.get_configdrive_image(self.node))
+        mock_cd.assert_called_once_with({'uuid': self.node.uuid},
+                                        network_data=None,
+                                        user_data=None,
+                                        vendor_data=None)
+
+    @mock.patch('openstack.baremetal.configdrive.build', autospec=True)
+    def test_build_populated(self, mock_cd):
+        configdrive = {
+            'meta_data': {'uuid': uuidutils.generate_uuid(),
+                          'name': 'new-name',
+                          'hostname': 'example.com'},
+            'network_data': {'links': []},
+            'vendor_data': {'foo': 'bar'},
+        }
+        self.node.instance_info['configdrive'] = configdrive
+        self.assertEqual(mock_cd.return_value,
+                         conductor_utils.get_configdrive_image(self.node))
+        mock_cd.assert_called_once_with(
+            configdrive['meta_data'],
+            network_data=configdrive['network_data'],
+            user_data=None,
+            vendor_data=configdrive['vendor_data'])
+
+    @mock.patch('openstack.baremetal.configdrive.build', autospec=True)
+    def test_build_user_data_as_string(self, mock_cd):
+        self.node.instance_info['configdrive'] = {'user_data': 'abcd'}
+        self.assertEqual(mock_cd.return_value,
+                         conductor_utils.get_configdrive_image(self.node))
+        mock_cd.assert_called_once_with({'uuid': self.node.uuid},
+                                        network_data=None,
+                                        user_data=b'abcd',
+                                        vendor_data=None)
+
+    @mock.patch('openstack.baremetal.configdrive.build', autospec=True)
+    def test_build_user_data_as_dict(self, mock_cd):
+        self.node.instance_info['configdrive'] = {
+            'user_data': {'user': 'data'}
+        }
+        self.assertEqual(mock_cd.return_value,
+                         conductor_utils.get_configdrive_image(self.node))
+        mock_cd.assert_called_once_with({'uuid': self.node.uuid},
+                                        network_data=None,
+                                        user_data=b'{"user": "data"}',
+                                        vendor_data=None)
diff --git a/ironic/tests/unit/drivers/modules/ansible/test_deploy.py b/ironic/tests/unit/drivers/modules/ansible/test_deploy.py
index 17ab45786d..886c01db5d 100644
--- a/ironic/tests/unit/drivers/modules/ansible/test_deploy.py
+++ b/ironic/tests/unit/drivers/modules/ansible/test_deploy.py
@@ -495,6 +495,36 @@ class TestAnsibleMethods(AnsibleDeployTestCaseBase):
                 mock.call().write('fake-content'),
                 mock.call().__exit__(None, None, None)))
 
+    @mock.patch.object(utils, 'build_configdrive', autospec=True)
+    def test__prepare_variables_configdrive_json(self, mock_build_configdrive):
+        i_info = self.node.instance_info
+        i_info['configdrive'] = {'meta_data': {}}
+        self.node.instance_info = i_info
+        self.node.save()
+        mock_build_configdrive.return_value = 'fake-content'
+        configdrive_path = ('%(tempdir)s/%(node)s.cndrive' %
+                            {'tempdir': ansible_deploy.CONF.tempdir,
+                             'node': self.node.uuid})
+        expected = {"image": {"url": "http://image",
+                              "validate_certs": "yes",
+                              "source": "fake-image",
+                              "disk_format": "qcow2",
+                              "checksum": "md5:checksum"},
+                    'configdrive': {'type': 'file',
+                                    'location': configdrive_path}}
+        with mock.patch.object(ansible_deploy, 'open', mock.mock_open(),
+                               create=True) as open_mock:
+            with task_manager.acquire(self.context, self.node.uuid) as task:
+                self.assertEqual(expected,
+                                 ansible_deploy._prepare_variables(task))
+                mock_build_configdrive.assert_called_once_with(
+                    task.node, {'meta_data': {}})
+            open_mock.assert_has_calls((
+                mock.call(configdrive_path, 'w'),
+                mock.call().__enter__(),
+                mock.call().write('fake-content'),
+                mock.call().__exit__(None, None, None)))
+
     def test__validate_clean_steps(self):
         steps = [{"interface": "deploy",
                   "name": "foo",
diff --git a/ironic/tests/unit/drivers/modules/redfish/test_boot.py b/ironic/tests/unit/drivers/modules/redfish/test_boot.py
index d4471cda51..50a525e473 100644
--- a/ironic/tests/unit/drivers/modules/redfish/test_boot.py
+++ b/ironic/tests/unit/drivers/modules/redfish/test_boot.py
@@ -849,15 +849,16 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase):
     @mock.patch.object(redfish_boot, '_eject_vmedia', autospec=True)
     @mock.patch.object(redfish_boot, '_insert_vmedia', autospec=True)
     @mock.patch.object(redfish_boot, '_parse_deploy_info', autospec=True)
-    @mock.patch.object(redfish_boot, 'manager_utils', autospec=True)
+    @mock.patch.object(redfish_boot.manager_utils, 'node_set_boot_device',
+                       autospec=True)
     @mock.patch.object(redfish_boot, 'deploy_utils', autospec=True)
     @mock.patch.object(redfish_boot, 'boot_mode_utils', autospec=True)
     @mock.patch.object(redfish_utils, 'get_system', autospec=True)
     def test_prepare_instance_ramdisk_boot(
             self, mock_system, mock_boot_mode_utils, mock_deploy_utils,
-            mock_manager_utils, mock__parse_deploy_info, mock__insert_vmedia,
-            mock__eject_vmedia, mock_prepare_boot_iso, mock_prepare_disk,
-            mock_clean_up_instance):
+            mock_node_set_boot_device, mock__parse_deploy_info,
+            mock__insert_vmedia, mock__eject_vmedia, mock_prepare_boot_iso,
+            mock_prepare_disk, mock_clean_up_instance):
 
         configdrive = 'Y29udGVudA=='
         managers = mock_system.return_value.managers
@@ -899,7 +900,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase):
                           'cd-url', sushy.VIRTUAL_MEDIA_USBSTICK),
             ])
 
-            mock_manager_utils.node_set_boot_device.assert_called_once_with(
+            mock_node_set_boot_device.assert_called_once_with(
                 task, boot_devices.CDROM, persistent=True)
 
             mock_boot_mode_utils.sync_boot_mode.assert_called_once_with(task)
@@ -910,14 +911,16 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase):
     @mock.patch.object(redfish_boot, '_eject_vmedia', autospec=True)
     @mock.patch.object(redfish_boot, '_insert_vmedia', autospec=True)
     @mock.patch.object(redfish_boot, '_parse_deploy_info', autospec=True)
-    @mock.patch.object(redfish_boot, 'manager_utils', autospec=True)
+    @mock.patch.object(redfish_boot.manager_utils, 'node_set_boot_device',
+                       autospec=True)
     @mock.patch.object(redfish_boot, 'deploy_utils', autospec=True)
     @mock.patch.object(redfish_boot, 'boot_mode_utils', autospec=True)
     @mock.patch.object(redfish_utils, 'get_system', autospec=True)
     def test_prepare_instance_ramdisk_boot_iso(
             self, mock_system, mock_boot_mode_utils, mock_deploy_utils,
-            mock_manager_utils, mock__parse_deploy_info, mock__insert_vmedia,
-            mock__eject_vmedia, mock_prepare_boot_iso, mock_clean_up_instance):
+            mock_node_set_boot_device, mock__parse_deploy_info,
+            mock__insert_vmedia, mock__eject_vmedia, mock_prepare_boot_iso,
+            mock_clean_up_instance):
 
         managers = mock_system.return_value.managers
         with task_manager.acquire(self.context, self.node.uuid,
@@ -948,7 +951,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase):
             mock__insert_vmedia.assert_called_once_with(
                 task, managers, 'image-url', sushy.VIRTUAL_MEDIA_CD)
 
-            mock_manager_utils.node_set_boot_device.assert_called_once_with(
+            mock_node_set_boot_device.assert_called_once_with(
                 task, boot_devices.CDROM, persistent=True)
 
             mock_boot_mode_utils.sync_boot_mode.assert_called_once_with(task)
@@ -959,14 +962,16 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase):
     @mock.patch.object(redfish_boot, '_eject_vmedia', autospec=True)
     @mock.patch.object(redfish_boot, '_insert_vmedia', autospec=True)
     @mock.patch.object(redfish_boot, '_parse_deploy_info', autospec=True)
-    @mock.patch.object(redfish_boot, 'manager_utils', autospec=True)
+    @mock.patch.object(redfish_boot.manager_utils, 'node_set_boot_device',
+                       autospec=True)
     @mock.patch.object(redfish_boot, 'deploy_utils', autospec=True)
     @mock.patch.object(redfish_boot, 'boot_mode_utils', autospec=True)
     @mock.patch.object(redfish_utils, 'get_system', autospec=True)
     def test_prepare_instance_ramdisk_boot_iso_boot(
             self, mock_system, mock_boot_mode_utils, mock_deploy_utils,
-            mock_manager_utils, mock__parse_deploy_info, mock__insert_vmedia,
-            mock__eject_vmedia, mock_prepare_boot_iso, mock_clean_up_instance):
+            mock_node_set_boot_device, mock__parse_deploy_info,
+            mock__insert_vmedia, mock__eject_vmedia, mock_prepare_boot_iso,
+            mock_clean_up_instance):
 
         managers = mock_system.return_value.managers
         with task_manager.acquire(self.context, self.node.uuid,
@@ -991,7 +996,76 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase):
             mock__insert_vmedia.assert_called_once_with(
                 task, managers, 'image-url', sushy.VIRTUAL_MEDIA_CD)
 
-            mock_manager_utils.node_set_boot_device.assert_called_once_with(
+            mock_node_set_boot_device.assert_called_once_with(
+                task, boot_devices.CDROM, persistent=True)
+
+            mock_boot_mode_utils.sync_boot_mode.assert_called_once_with(task)
+
+    @mock.patch.object(redfish_boot.manager_utils, 'build_configdrive',
+                       autospec=True)
+    @mock.patch.object(redfish_boot.RedfishVirtualMediaBoot,
+                       '_eject_all', autospec=True)
+    @mock.patch.object(image_utils, 'prepare_configdrive_image', autospec=True)
+    @mock.patch.object(image_utils, 'prepare_boot_iso', autospec=True)
+    @mock.patch.object(redfish_boot, '_eject_vmedia', autospec=True)
+    @mock.patch.object(redfish_boot, '_insert_vmedia', autospec=True)
+    @mock.patch.object(redfish_boot, '_parse_deploy_info', autospec=True)
+    @mock.patch.object(redfish_boot.manager_utils, 'node_set_boot_device',
+                       autospec=True)
+    @mock.patch.object(redfish_boot, 'deploy_utils', autospec=True)
+    @mock.patch.object(redfish_boot, 'boot_mode_utils', autospec=True)
+    @mock.patch.object(redfish_utils, 'get_system', autospec=True)
+    def test_prepare_instance_ramdisk_boot_render_configdrive(
+            self, mock_system, mock_boot_mode_utils, mock_deploy_utils,
+            mock_node_set_boot_device, mock__parse_deploy_info,
+            mock__insert_vmedia, mock__eject_vmedia, mock_prepare_boot_iso,
+            mock_prepare_disk, mock_clean_up_instance, mock_build_configdrive):
+
+        configdrive = 'Y29udGVudA=='
+        managers = mock_system.return_value.managers
+        with task_manager.acquire(self.context, self.node.uuid,
+                                  shared=True) as task:
+            task.node.provision_state = states.DEPLOYING
+            task.node.driver_internal_info[
+                'root_uuid_or_disk_id'] = self.node.uuid
+            task.node.instance_info['configdrive'] = {'meta_data': {}}
+
+            mock_build_configdrive.return_value = configdrive
+
+            mock_deploy_utils.get_boot_option.return_value = 'ramdisk'
+
+            d_info = {
+                'deploy_kernel': 'kernel',
+                'deploy_ramdisk': 'ramdisk',
+                'bootloader': 'bootloader'
+            }
+            mock__parse_deploy_info.return_value = d_info
+
+            mock_prepare_boot_iso.return_value = 'image-url'
+            mock_prepare_disk.return_value = 'cd-url'
+
+            task.driver.boot.prepare_instance(task)
+
+            mock_clean_up_instance.assert_called_once_with(mock.ANY, task)
+
+            mock_build_configdrive.assert_called_once_with(
+                task.node, {'meta_data': {}})
+            mock_prepare_boot_iso.assert_called_once_with(task, d_info)
+            mock_prepare_disk.assert_called_once_with(task, configdrive)
+
+            mock__eject_vmedia.assert_has_calls([
+                mock.call(task, managers, sushy.VIRTUAL_MEDIA_CD),
+                mock.call(task, managers, sushy.VIRTUAL_MEDIA_USBSTICK),
+            ])
+
+            mock__insert_vmedia.assert_has_calls([
+                mock.call(task, managers,
+                          'image-url', sushy.VIRTUAL_MEDIA_CD),
+                mock.call(task, managers,
+                          'cd-url', sushy.VIRTUAL_MEDIA_USBSTICK),
+            ])
+
+            mock_node_set_boot_device.assert_called_once_with(
                 task, boot_devices.CDROM, persistent=True)
 
             mock_boot_mode_utils.sync_boot_mode.assert_called_once_with(task)
diff --git a/ironic/tests/unit/drivers/modules/test_agent.py b/ironic/tests/unit/drivers/modules/test_agent.py
index 6ab5d71d06..2fad169aac 100644
--- a/ironic/tests/unit/drivers/modules/test_agent.py
+++ b/ironic/tests/unit/drivers/modules/test_agent.py
@@ -1482,6 +1482,73 @@ class TestAgentDeploy(CommonTestsMixin, db_base.DbTestCase):
             self.assertEqual(states.ACTIVE,
                              task.node.target_provision_state)
 
+    @mock.patch.object(manager_utils, 'build_configdrive', autospec=True)
+    def test_write_image_render_configdrive(self, mock_build_configdrive):
+        self.node.provision_state = states.DEPLOYWAIT
+        self.node.target_provision_state = states.ACTIVE
+        i_info = self.node.instance_info
+        i_info['kernel'] = 'kernel'
+        i_info['ramdisk'] = 'ramdisk'
+        i_info['root_gb'] = 10
+        i_info['swap_mb'] = 10
+        i_info['ephemeral_mb'] = 0
+        i_info['ephemeral_format'] = 'abc'
+        i_info['configdrive'] = {'meta_data': {}}
+        i_info['preserve_ephemeral'] = False
+        i_info['image_type'] = 'partition'
+        i_info['root_mb'] = 10240
+        i_info['deploy_boot_mode'] = 'bios'
+        i_info['capabilities'] = {"boot_option": "local",
+                                  "disk_label": "msdos"}
+        self.node.instance_info = i_info
+        driver_internal_info = self.node.driver_internal_info
+        driver_internal_info['is_whole_disk_image'] = False
+        self.node.driver_internal_info = driver_internal_info
+        self.node.save()
+        test_temp_url = 'http://image'
+        expected_image_info = {
+            'urls': [test_temp_url],
+            'id': 'fake-image',
+            'node_uuid': self.node.uuid,
+            'checksum': 'checksum',
+            'disk_format': 'qcow2',
+            'container_format': 'bare',
+            'stream_raw_images': True,
+            'kernel': 'kernel',
+            'ramdisk': 'ramdisk',
+            'root_gb': 10,
+            'swap_mb': 10,
+            'ephemeral_mb': 0,
+            'ephemeral_format': 'abc',
+            'configdrive': 'configdrive',
+            'preserve_ephemeral': False,
+            'image_type': 'partition',
+            'root_mb': 10240,
+            'boot_option': 'local',
+            'deploy_boot_mode': 'bios',
+            'disk_label': 'msdos'
+        }
+
+        mock_build_configdrive.return_value = 'configdrive'
+
+        client_mock = mock.MagicMock(spec_set=['execute_deploy_step'])
+
+        with task_manager.acquire(self.context, self.node.uuid,
+                                  shared=False) as task:
+            task.driver.deploy._client = client_mock
+            task.driver.deploy.write_image(task)
+
+            step = {'step': 'write_image', 'interface': 'deploy',
+                    'args': {'image_info': expected_image_info,
+                             'configdrive': 'configdrive'}}
+            client_mock.execute_deploy_step.assert_called_once_with(
+                step, task.node, mock.ANY)
+            self.assertEqual(states.DEPLOYWAIT, task.node.provision_state)
+            self.assertEqual(states.ACTIVE,
+                             task.node.target_provision_state)
+            mock_build_configdrive.assert_called_once_with(
+                task.node, {'meta_data': {}})
+
     @mock.patch.object(deploy_utils, 'remove_http_instance_symlink',
                        autospec=True)
     @mock.patch.object(agent.LOG, 'warning', spec_set=True, autospec=True)
diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py
index a9dd2684b2..ab2b9cec88 100644
--- a/ironic/tests/unit/objects/test_node.py
+++ b/ironic/tests/unit/objects/test_node.py
@@ -61,6 +61,18 @@ class TestNodeObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn):
         # Ensure the node can be serialised.
         jsonutils.dumps(d)
 
+    def test_as_dict_secure_configdrive_as_dict(self):
+        self.node.driver_info['ipmi_password'] = 'fake'
+        self.node.instance_info['configdrive'] = {'user_data': 'data'}
+        self.node.driver_internal_info['agent_secret_token'] = 'abc'
+        d = self.node.as_dict(secure=True)
+        self.assertEqual('******', d['driver_info']['ipmi_password'])
+        self.assertEqual('******', d['instance_info']['configdrive'])
+        self.assertEqual('******',
+                         d['driver_internal_info']['agent_secret_token'])
+        # Ensure the node can be serialised.
+        jsonutils.dumps(d)
+
     def test_as_dict_secure_with_configdrive(self):
         self.node.driver_info['ipmi_password'] = 'fake'
         self.node.instance_info['configdrive'] = 'data'
@@ -73,6 +85,19 @@ class TestNodeObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn):
         # Ensure the node can be serialised.
         jsonutils.dumps(d)
 
+    def test_as_dict_secure_with_configdrive_as_dict(self):
+        self.node.driver_info['ipmi_password'] = 'fake'
+        self.node.instance_info['configdrive'] = {'user_data': 'data'}
+        self.node.driver_internal_info['agent_secret_token'] = 'abc'
+        d = self.node.as_dict(secure=True, mask_configdrive=False)
+        self.assertEqual('******', d['driver_info']['ipmi_password'])
+        self.assertEqual({'user_data': 'data'},
+                         d['instance_info']['configdrive'])
+        self.assertEqual('******',
+                         d['driver_internal_info']['agent_secret_token'])
+        # Ensure the node can be serialised.
+        jsonutils.dumps(d)
+
     def test_as_dict_with_traits(self):
         self.fake_node['traits'] = ['CUSTOM_1']
         self.node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
diff --git a/releasenotes/notes/configdrive-render-8eb398d956393d60.yaml b/releasenotes/notes/configdrive-render-8eb398d956393d60.yaml
new file mode 100644
index 0000000000..889c4b76e0
--- /dev/null
+++ b/releasenotes/notes/configdrive-render-8eb398d956393d60.yaml
@@ -0,0 +1,6 @@
+---
+other:
+  - |
+    Configuration drives are now stored in their JSON representation and only
+    rendered when needed. This allows deploy steps to access the original
+    JSON representation rather than only the rendered ISO image.