From 33bb2c248a20d6a2a0af570655124cbc86d58b6a Mon Sep 17 00:00:00 2001 From: Julia Kreger Date: Tue, 29 Mar 2022 18:32:36 -0700 Subject: [PATCH] Do not require stage2 for anaconda with standalone The use of the anaconda deployment interface can be confusing when using a standalone deployment model. Specifically this is because the anaconda deployment interface was primarily modeled for usage with glance and the inherent configuration of a fully integrated OpenStack deployment. The additional prameters are confusing, so this also (hopefully) provides clarity into use and options. Change-Id: I748fd86901bc05d3d003626b5e14e655b7905215 --- .../admin/anaconda-deploy-interface.rst | 29 ++++++ ironic/common/pxe_utils.py | 55 +++++++++--- ironic/drivers/modules/ipxe_config.template | 2 +- ironic/tests/unit/common/test_pxe_utils.py | 88 +++++++++++++++++++ .../ipxe_config_boot_from_anaconda.template | 47 ++++++++++ ...ndalone-anaconda-use-7160d0d3401e468e.yaml | 11 +++ 6 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 ironic/tests/unit/drivers/ipxe_config_boot_from_anaconda.template create mode 100644 releasenotes/notes/simplify-standalone-anaconda-use-7160d0d3401e468e.yaml diff --git a/doc/source/admin/anaconda-deploy-interface.rst b/doc/source/admin/anaconda-deploy-interface.rst index e2dad8febc..e715a1b237 100644 --- a/doc/source/admin/anaconda-deploy-interface.rst +++ b/doc/source/admin/anaconda-deploy-interface.rst @@ -218,6 +218,35 @@ collects the files, and stages them appropriately. At this point, you should be able to request the baremetal node to deploy. +Standalone using a repository +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Anaconda supports a concept of passing a repository as opposed to a dedicated +URL path which has a ``.treeinfo`` file, which tells the initial boot scripts +where to get various dependencies, such as what would be used as the anaconda +``stage2`` ramdisk. Unfortunately, this functionality is not well documented. + +An example ``.treeinfo`` file can be found at +http://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os/.treeinfo. + +.. note:: + In the context of the ``.treeinfo`` file and the related folder structure + for a deployment utilizing the ``anaconda`` deployment interface, + ``images/install.img`` file represents a ``stage2`` ramdisk. + +In the context of one wishing to deploy Centos Stream-9, the following may +be useful. + +.. code-block:: shell + + baremetal node set \ + --instance_info image_source=http://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os/ \ + --instance_info kernel=http://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os/images/pxeboot/vmlinuz \ + --instance_info ramdisk=http://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os/images/pxeboot/initrd.img + +Once set, a kickstart template can be provided via an ``instance_info`` +parameter, and the node deployed. + Deployment Process ------------------ diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py index b0f1d906fc..64cf4608f4 100644 --- a/ironic/common/pxe_utils.py +++ b/ironic/common/pxe_utils.py @@ -698,17 +698,25 @@ def get_instance_image_info(task, ipxe_enabled=False): anaconda_labels = () if deploy_utils.get_boot_option(node) == 'kickstart': + isap = node.driver_internal_info.get('is_source_a_path') # stage2: installer stage2 squashfs image # ks_template: anaconda kickstart template # ks_cfg - rendered ks_template - anaconda_labels = ('stage2', 'ks_template', 'ks_cfg') + if not isap: + anaconda_labels = ('stage2', 'ks_template', 'ks_cfg') + else: + # When a path is used, a stage2 ramdisk can be determiend + # automatically by anaconda, so it is not an explicit + # requirement. + anaconda_labels = ('ks_template', 'ks_cfg') # NOTE(rloo): We save stage2 & ks_template values in case they # are changed by the user after we start using them and to # prevent re-computing them again. if not node.driver_internal_info.get('stage2'): if i_info.get('stage2'): node.set_driver_internal_info('stage2', i_info['stage2']) - else: + elif not isap: + # If the source is not a path, then we need a stage2 ramdisk. _get_image_properties() if 'stage2_id' not in image_properties: msg = (_("'stage2_id' is missing from the properties of " @@ -720,19 +728,27 @@ def get_instance_image_info(task, ipxe_enabled=False): else: node.set_driver_internal_info( 'stage2', str(image_properties['stage2_id'])) - if i_info.get('ks_template'): - node.set_driver_internal_info('ks_template', - i_info['ks_template']) + # NOTE(TheJulia): A kickstart template is entirely independent + # of the stage2 ramdisk. In the end, it was the configuration which + # told anaconda how to execute. + if i_info.get('ks_template'): + # If the value is set, we always overwrite it, in the event + # a rebuild is occuring or something along those lines. + node.set_driver_internal_info('ks_template', + i_info['ks_template']) + else: + _get_image_properties() + # ks_template is an optional property on the image + if 'ks_template' not in image_properties: + # If not defined, default to the overall system default + # kickstart template, as opposed to a user supplied + # template. + node.set_driver_internal_info( + 'ks_template', CONF.anaconda.default_ks_template) else: - _get_image_properties() - # ks_template is an optional property on the image - if 'ks_template' not in image_properties: - node.set_driver_internal_info( - 'ks_template', CONF.anaconda.default_ks_template) - else: - node.set_driver_internal_info( - 'ks_template', str(image_properties['ks_template'])) - node.save() + node.set_driver_internal_info( + 'ks_template', str(image_properties['ks_template'])) + node.save() for label in labels + anaconda_labels: image_info[label] = ( @@ -800,6 +816,7 @@ def build_deploy_pxe_options(task, pxe_info, mode='deploy', def build_instance_pxe_options(task, pxe_info, ipxe_enabled=False): pxe_opts = {} node = task.node + isap = node.driver_internal_info.get('is_source_a_path') for label, option in (('kernel', 'aki_path'), ('ramdisk', 'ari_path'), @@ -822,6 +839,16 @@ def build_instance_pxe_options(task, pxe_info, ipxe_enabled=False): pxe_opts[option] = os.path.relpath(pxe_info[label][1], CONF.pxe.tftp_root) + # NOTE(TheJulia): This is basically anaconda specific, but who knows + # one day! Copy image_source to repo_url if it is a URL to a directory + # path, and an explicit stage2 URL is not defined as .treeinfo is totally + # a thing and anaconda's dracut element knows the secrets of how to + # get and use the treeinfo file. And yes, this is a hidden file. :\ + # example: + # http://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os/.treeinfo + if isap and 'stage2_url' not in pxe_opts: + pxe_opts['repo_url'] = node.instance_info.get('image_source') + pxe_opts.setdefault('aki_path', 'no_kernel') pxe_opts.setdefault('ari_path', 'no_ramdisk') diff --git a/ironic/drivers/modules/ipxe_config.template b/ironic/drivers/modules/ipxe_config.template index 32afea5ec6..bca63c9824 100644 --- a/ironic/drivers/modules/ipxe_config.template +++ b/ironic/drivers/modules/ipxe_config.template @@ -33,7 +33,7 @@ boot :boot_anaconda imgfree -kernel {% if pxe_options.ipxe_timeout > 0 %}--timeout {{ pxe_options.ipxe_timeout }} {% endif %}{{ pxe_options.aki_path }} text {{ pxe_options.pxe_append_params|default("", true) }} inst.ks={{ pxe_options.ks_cfg_url }} inst.stage2={{ pxe_options.stage2_url }} initrd=ramdisk || goto boot_anaconda +kernel {% if pxe_options.ipxe_timeout > 0 %}--timeout {{ pxe_options.ipxe_timeout }} {% endif %}{{ pxe_options.aki_path }} text {{ pxe_options.pxe_append_params|default("", true) }} inst.ks={{ pxe_options.ks_cfg_url }} {% if pxe_options.repo_url %}inst.repo={{ pxe_options.repo_url }}{% else %}inst.stage2={{ pxe_options.stage2_url }}{% endif %} initrd=ramdisk || goto boot_anaconda initrd {% if pxe_options.ipxe_timeout > 0 %}--timeout {{ pxe_options.ipxe_timeout }} {% endif %}{{ pxe_options.ari_path }} || goto boot_anaconda boot diff --git a/ironic/tests/unit/common/test_pxe_utils.py b/ironic/tests/unit/common/test_pxe_utils.py index f38e7127ad..beedb6f78e 100644 --- a/ironic/tests/unit/common/test_pxe_utils.py +++ b/ironic/tests/unit/common/test_pxe_utils.py @@ -133,6 +133,17 @@ class TestPXEUtils(db_base.DbTestCase): 'ramdisk_kernel_arguments': 'ramdisk_params' }) + self.ipxe_kickstart_deploy = self.pxe_options.copy() + self.ipxe_kickstart_deploy.update({ + 'deployment_aki_path': 'http://1.2.3.4:1234/deploy_kernel', + 'deployment_ari_path': 'http://1.2.3.4:1234/deploy_ramdisk', + 'aki_path': 'http://1.2.3.4:1234/kernel', + 'ari_path': 'http://1.2.3.4:1234/ramdisk', + 'initrd_filename': 'deploy_ramdisk', + 'repo_url': 'http://1.2.3.4/path/to/os/', + }) + self.ipxe_kickstart_deploy.pop('stage2_url') + self.node = object_utils.create_test_node(self.context) def test_default_pxe_config(self): @@ -315,6 +326,27 @@ class TestPXEUtils(db_base.DbTestCase): expected_template = f.read().rstrip() self.assertEqual(str(expected_template), rendered_template) + def test_default_ipxe_boot_from_anaconda(self): + self.config( + pxe_config_template='ironic/drivers/modules/ipxe_config.template', + group='pxe' + ) + self.config(http_url='http://1.2.3.4:1234', group='deploy') + + pxe_options = self.ipxe_kickstart_deploy + + rendered_template = utils.render_template( + CONF.pxe.ipxe_config_template, + {'pxe_options': pxe_options, + 'ROOT': '{{ ROOT }}'}, + ) + + templ_file = 'ironic/tests/unit/drivers/' \ + 'ipxe_config_boot_from_anaconda.template' + with open(templ_file) as f: + expected_template = f.read().rstrip() + self.assertEqual(str(expected_template), rendered_template) + def test_default_grub_config(self): pxe_opts = self.pxe_options pxe_opts['boot_mode'] = 'uefi' @@ -1375,6 +1407,62 @@ class PXEInterfacesTestCase(db_base.DbTestCase): self.assertEqual('https://server/fake.tmpl', image_info['ks_template'][0]) + @mock.patch('ironic.drivers.modules.deploy_utils.get_boot_option', + return_value='kickstart', autospec=True) + @mock.patch.object(image_service.GlanceImageService, 'show', autospec=True) + def test_get_instance_image_info_with_kickstart_url( + self, image_show_mock, boot_opt_mock): + properties = {'properties': {u'kernel_id': u'instance_kernel_uuid', + u'ramdisk_id': u'instance_ramdisk_uuid', + u'image_source': u'http://path/to/os/'}} + + expected_info = {'ramdisk': + ('instance_ramdisk_uuid', + os.path.join(CONF.pxe.tftp_root, + self.node.uuid, + 'ramdisk')), + 'kernel': + ('instance_kernel_uuid', + os.path.join(CONF.pxe.tftp_root, + self.node.uuid, + 'kernel')), + 'ks_template': + (CONF.anaconda.default_ks_template, + os.path.join(CONF.deploy.http_root, + self.node.uuid, + 'ks.cfg.template')), + 'ks_cfg': + ('', + os.path.join(CONF.deploy.http_root, + self.node.uuid, + 'ks.cfg'))} + image_show_mock.return_value = properties + self.context.auth_token = 'fake' + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + dii = task.node.driver_internal_info + dii['is_source_a_path'] = True + task.node.driver_internal_info = dii + task.node.save() + image_info = pxe_utils.get_instance_image_info( + task, ipxe_enabled=False) + self.assertEqual(expected_info, image_info) + # In the absense of kickstart template in both instance_info and + # image default kickstart template is used + self.assertEqual(CONF.anaconda.default_ks_template, + image_info['ks_template'][0]) + calls = [mock.call(task.node), mock.call(task.node)] + boot_opt_mock.assert_has_calls(calls) + # Instance info gets presedence over kickstart template on the + # image + properties['properties'] = {'ks_template': 'glance://template_id'} + task.node.instance_info['ks_template'] = 'https://server/fake.tmpl' + image_show_mock.return_value = properties + image_info = pxe_utils.get_instance_image_info( + task, ipxe_enabled=False) + self.assertEqual('https://server/fake.tmpl', + image_info['ks_template'][0]) + @mock.patch('ironic.drivers.modules.deploy_utils.get_boot_option', return_value='kickstart', autospec=True) @mock.patch.object(image_service.GlanceImageService, 'show', autospec=True) diff --git a/ironic/tests/unit/drivers/ipxe_config_boot_from_anaconda.template b/ironic/tests/unit/drivers/ipxe_config_boot_from_anaconda.template new file mode 100644 index 0000000000..7963b38833 --- /dev/null +++ b/ironic/tests/unit/drivers/ipxe_config_boot_from_anaconda.template @@ -0,0 +1,47 @@ +#!ipxe + +set attempts:int32 10 +set i:int32 0 + +goto deploy + +:deploy +imgfree +kernel http://1.2.3.4:1234/deploy_kernel selinux=0 troubleshoot=0 text test_param BOOTIF=${mac} initrd=deploy_ramdisk || goto retry + +initrd http://1.2.3.4:1234/deploy_ramdisk || goto retry +boot + +:retry +iseq ${i} ${attempts} && goto fail || +inc i +echo No response, retrying in ${i} seconds. +sleep ${i} +goto deploy + +:fail +echo Failed to get a response after ${attempts} attempts +echo Powering off in 30 seconds. +sleep 30 +poweroff + +:boot_partition +imgfree +kernel http://1.2.3.4:1234/kernel root={{ ROOT }} ro text test_param initrd=ramdisk || goto boot_partition +initrd http://1.2.3.4:1234/ramdisk || goto boot_partition +boot + +:boot_anaconda +imgfree +kernel http://1.2.3.4:1234/kernel text test_param inst.ks=http://fake/ks.cfg inst.repo=http://1.2.3.4/path/to/os/ initrd=ramdisk || goto boot_anaconda +initrd http://1.2.3.4:1234/ramdisk || goto boot_anaconda +boot + +:boot_ramdisk +imgfree +kernel http://1.2.3.4:1234/kernel root=/dev/ram0 text test_param ramdisk_param initrd=ramdisk || goto boot_ramdisk +initrd http://1.2.3.4:1234/ramdisk || goto boot_ramdisk +boot + +:boot_whole_disk +sanboot --no-describe diff --git a/releasenotes/notes/simplify-standalone-anaconda-use-7160d0d3401e468e.yaml b/releasenotes/notes/simplify-standalone-anaconda-use-7160d0d3401e468e.yaml new file mode 100644 index 0000000000..2178fe8cec --- /dev/null +++ b/releasenotes/notes/simplify-standalone-anaconda-use-7160d0d3401e468e.yaml @@ -0,0 +1,11 @@ +--- +fixes: + - | + Anaconda supports the ability to explicitly pass a URL instead + of a ``stage2`` ramdisk parameter. This has resulted in confusion + in use of the ``anaconda`` deployment interface, as a ``stage2`` + ramdisk is typically not used, but made sense with Glance images in + a fully integrated OpenStack deployment. Now a URL to a path can be + supplied to the ``anaconda`` deployment interface to simplify the + interaction and use, and a redundant ``stage2`` parameter is no longer + required.