diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index 1503fdd210..a1da1fb7e2 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -16,6 +16,7 @@ from oslo_config import cfg from ironic.conf import agent +from ironic.conf import anaconda from ironic.conf import ansible from ironic.conf import api from ironic.conf import audit @@ -68,6 +69,7 @@ inspector.register_opts(CONF) ipmi.register_opts(CONF) irmc.register_opts(CONF) iscsi.register_opts(CONF) +anaconda.register_opts(CONF) metrics.register_opts(CONF) metrics_statsd.register_opts(CONF) neutron.register_opts(CONF) diff --git a/ironic/conf/anaconda.py b/ironic/conf/anaconda.py new file mode 100644 index 0000000000..8ae3ab5330 --- /dev/null +++ b/ironic/conf/anaconda.py @@ -0,0 +1,36 @@ +# Copyright 2021 Verizon Media +# +# 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 os + +from oslo_config import cfg + +from ironic.common.i18n import _ + + +ks_group = cfg.OptGroup(name='anaconda', + title='Anaconda/kickstart interface options') +opts = [ + cfg.StrOpt('default_ks_template', + default=os.path.join( + '$pybasedir', 'drivers/modules/ks.cfg.template'), + mutable=True, + help=_('kickstart template to use when no kickstart template ' + 'is specified in the instance_info or the glance OS ' + 'image.')), +] + + +def register_opts(conf): + conf.register_group(ks_group) + conf.register_opts(opts, group='anaconda') diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index 1cde057cb2..976611be66 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -51,6 +51,7 @@ _opts = [ ('ipmi', ironic.conf.ipmi.opts), ('irmc', ironic.conf.irmc.opts), ('iscsi', ironic.conf.iscsi.opts), + ('anaconda', ironic.conf.anaconda.opts), ('metrics', ironic.conf.metrics.opts), ('metrics_statsd', ironic.conf.metrics_statsd.opts), ('neutron', ironic.conf.neutron.list_opts()), diff --git a/ironic/drivers/generic.py b/ironic/drivers/generic.py index 956119df8f..e8b359f18e 100644 --- a/ironic/drivers/generic.py +++ b/ironic/drivers/generic.py @@ -50,7 +50,8 @@ class GenericHardware(hardware_type.AbstractHardwareType): def supported_deploy_interfaces(self): """List of supported deploy interfaces.""" return [agent.AgentDeploy, iscsi_deploy.ISCSIDeploy, - ansible_deploy.AnsibleDeploy, pxe.PXERamdiskDeploy] + ansible_deploy.AnsibleDeploy, pxe.PXERamdiskDeploy, + pxe.PXEAnacondaDeploy] @property def supported_inspect_interfaces(self): diff --git a/ironic/drivers/modules/deploy_utils.py b/ironic/drivers/modules/deploy_utils.py index bc9a7114ea..432bddbabe 100644 --- a/ironic/drivers/modules/deploy_utils.py +++ b/ironic/drivers/modules/deploy_utils.py @@ -55,7 +55,7 @@ LOG = logging.getLogger(__name__) METRICS = metrics_utils.get_metrics_logger(__name__) SUPPORTED_CAPABILITIES = { - 'boot_option': ('local', 'netboot', 'ramdisk'), + 'boot_option': ('local', 'netboot', 'ramdisk', 'kickstart'), 'boot_mode': ('bios', 'uefi'), 'secure_boot': ('true', 'false'), 'trusted_boot': ('true', 'false'), @@ -581,11 +581,25 @@ def get_boot_option(node): # NOTE(TheJulia): Software raid always implies local deployment if is_software_raid(node): return 'local' + if is_anaconda_deploy(node): + return 'kickstart' capabilities = utils.parse_instance_info_capabilities(node) return capabilities.get('boot_option', CONF.deploy.default_boot_option).lower() +def is_anaconda_deploy(node): + """Determine if Anaconda deploy interface is in use for the deployment. + + :param node: A single Node. + :returns: A boolean value of True when Anaconda deploy interface is in use + otherwise False + """ + if node.deploy_interface == 'anaconda': + return True + return False + + def is_software_raid(node): """Determine if software raid is in use for the deployment. diff --git a/ironic/drivers/modules/ks.cfg.template b/ironic/drivers/modules/ks.cfg.template new file mode 100644 index 0000000000..3d74c4f3c8 --- /dev/null +++ b/ironic/drivers/modules/ks.cfg.template @@ -0,0 +1,37 @@ +lang en_US +keyboard us +timezone UTC --utc +#platform x86, AMD64, or Intel EM64T +text +cmdline +reboot +selinux --enforcing +firewall --enabled +firstboot --disabled + +bootloader --location=mbr --append="rhgb quiet crashkernel=auto" +zerombr +clearpart --all --initlabel +autopart + +# Downloading and installing OS image using liveimg section is mandatory +liveimg --url {{ ks_options.liveimg_url }} + +# Following %pre, %onerror and %trackback sections are mandatory +%pre +/usr/bin/curl -X PUT -H 'Content-Type: application/json' -H 'Accept:application/json' -d '{"agent_token": "{{ ks_options.agent_token }}", "agent_state": "start", "agent_status": "Deployment starting. Running pre-installation scripts."}' {{ ks_options.heartbeat_url }} +%end + +%onerror +/usr/bin/curl -X PUT -H 'Content-Type: application/json' -H 'Accept:application/json' -d '{"agent_token": "{{ ks_options.agent_token }}", "agent_state": "error", "agent_status": "Error: Deploying using anaconda. Check console for more information."}' {{ ks_options.heartbeat_url }} +%end + +%traceback +/usr/bin/curl -X PUT -H 'Content-Type: application/json' -H 'Accept:application/json' -d '{"agent_token": "{{ ks_options.agent_token }}", "agent_state": "error", "agent_status": "Error: Installer crashed unexpectedly."}' {{ ks_options.heartbeat_url }} +%end + +# Sending callback after the installation is mandatory +%post +/usr/bin/curl -X PUT -H 'Content-Type: application/json' -H 'Accept:application/json' -d '{"agent_token": "{{ ks_options.agent_token }}", "agent_state": "end", "agent_status": "Deployment completed successfully."}' {{ ks_options.heartbeat_url }} +%end + diff --git a/ironic/drivers/modules/pxe.py b/ironic/drivers/modules/pxe.py index 81e04eb758..97f8e5961f 100644 --- a/ironic/drivers/modules/pxe.py +++ b/ironic/drivers/modules/pxe.py @@ -116,3 +116,24 @@ class PXERamdiskDeploy(agent_base.AgentBaseMixin, agent_base.HeartbeatMixin, if node.provision_state in (states.ACTIVE, states.UNRESCUING): # In the event of takeover or unrescue. task.driver.boot.prepare_instance(task) + + +class PXEAnacondaDeploy(agent_base.AgentBaseMixin, agent_base.HeartbeatMixin, + base.DeployInterface): + + def get_properties(self, task): + return {} + + def validate(self, task): + pass + + @METRICS.timer('AnacondaDeploy.deploy') + @base.deploy_step(priority=100) + @task_manager.require_exclusive_lock + def deploy(self, task): + pass + + @METRICS.timer('AnacondaDeploy.prepare') + @task_manager.require_exclusive_lock + def prepare(self, task): + pass diff --git a/ironic/drivers/modules/pxe_base.py b/ironic/drivers/modules/pxe_base.py index 70162a2eec..c0b04309be 100644 --- a/ironic/drivers/modules/pxe_base.py +++ b/ironic/drivers/modules/pxe_base.py @@ -331,6 +331,21 @@ class PXEBaseMixin(object): "iPXE boot is enabled but no HTTP URL or HTTP " "root was specified.")) + # NOTE(zer0c00l): When 'kickstart' boot option is used we need to store + # kickstart and squashfs files in http_root directory. These files + # will be eventually requested by anaconda installer during deployment + # over http(s). + if deploy_utils.get_boot_option(node) == 'kickstart': + if not CONF.deploy.http_url or not CONF.deploy.http_root: + raise exception.MissingParameterValue(_( + "'kickstart' boot option is set on the node but no HTTP " + "URL or HTTP root was specified.")) + + if not CONF.anaconda.default_ks_template: + raise exception.MissingParameterValue(_( + "'kickstart' boot option is set on the node but no " + "default kickstart template is specified.")) + # Check the trusted_boot capabilities value. deploy_utils.validate_capabilities(node) if deploy_utils.is_trusted_boot_requested(node): @@ -390,6 +405,8 @@ class PXEBaseMixin(object): props = ['boot_iso'] elif service_utils.is_glance_image(d_info['image_source']): props = ['kernel_id', 'ramdisk_id'] + if deploy_utils.get_boot_option(node) == 'kickstart': + props.append('squashfs_id') else: props = ['kernel', 'ramdisk'] deploy_utils.validate_image_properties(task.context, d_info, props) diff --git a/ironic/tests/unit/drivers/modules/test_deploy_utils.py b/ironic/tests/unit/drivers/modules/test_deploy_utils.py index 03eba20114..da2c4e9ff6 100644 --- a/ironic/tests/unit/drivers/modules/test_deploy_utils.py +++ b/ironic/tests/unit/drivers/modules/test_deploy_utils.py @@ -772,6 +772,21 @@ class OtherFunctionTestCase(db_base.DbTestCase): result = utils.get_boot_option(self.node) self.assertEqual("local", result) + @mock.patch.object(utils, 'is_anaconda_deploy', autospec=True) + def test_get_boot_option_anaconda_deploy(self, mock_is_anaconda_deploy): + mock_is_anaconda_deploy.return_value = True + result = utils.get_boot_option(self.node) + self.assertEqual("kickstart", result) + + def test_is_anaconda_deploy(self): + self.node.deploy_interface = 'anaconda' + result = utils.is_anaconda_deploy(self.node) + self.assertTrue(result) + + def test_is_anaconda_deploy_false(self): + result = utils.is_anaconda_deploy(self.node) + self.assertFalse(result) + def test_is_software_raid(self): self.node.target_raid_config = { "logical_disks": [ @@ -989,7 +1004,7 @@ class ParseInstanceInfoCapabilitiesTestCase(tests_base.TestCase): utils.validate_capabilities, self.node) def test_all_supported_capabilities(self): - self.assertEqual(('local', 'netboot', 'ramdisk'), + self.assertEqual(('local', 'netboot', 'ramdisk', 'kickstart'), utils.SUPPORTED_CAPABILITIES['boot_option']) self.assertEqual(('bios', 'uefi'), utils.SUPPORTED_CAPABILITIES['boot_mode']) diff --git a/ironic/tests/unit/drivers/modules/test_pxe.py b/ironic/tests/unit/drivers/modules/test_pxe.py index da1d9da973..d9fdb63ad6 100644 --- a/ironic/tests/unit/drivers/modules/test_pxe.py +++ b/ironic/tests/unit/drivers/modules/test_pxe.py @@ -70,11 +70,15 @@ class PXEBootTestCase(db_base.DbTestCase): self.config_temp_dir('tftp_root', group='pxe') self.config_temp_dir('images_path', group='pxe') self.config_temp_dir('http_root', group='deploy') + self.config(default_ks_template='/etc/ironic/ks.cfg.template', + group='anaconda') instance_info = INST_INFO_DICT instance_info['deploy_key'] = 'fake-56789' self.config(enabled_boot_interfaces=[self.boot_interface, 'ipxe', 'fake']) + self.config(enabled_deploy_interfaces=['fake', 'direct', 'iscsi', + 'anaconda']) self.node = obj_utils.create_test_node( self.context, driver=self.driver, @@ -223,6 +227,27 @@ class PXEBootTestCase(db_base.DbTestCase): self.assertRaises(exception.UnsupportedDriverExtension, task.driver.boot.validate_inspection, task) + @mock.patch.object(deploy_utils, 'validate_image_properties', + autospec=True) + def test_validate_kickstart_has_squashfs_id(self, mock_validate_img): + node = self.node + node.deploy_interface = 'anaconda' + node.save() + self.config(http_url='http://fake_url', group='deploy') + with task_manager.acquire(self.context, node.uuid) as task: + task.driver.boot.validate(task) + mock_validate_img.assert_called_once_with( + mock.ANY, mock.ANY, ['kernel_id', 'ramdisk_id', 'squashfs_id'] + ) + + def test_validate_kickstart_fail_http_url_not_set(self): + node = self.node + node.deploy_interface = 'anaconda' + node.save() + with task_manager.acquire(self.context, node.uuid) as task: + self.assertRaises(exception.MissingParameterValue, + task.driver.boot.validate, task) + @mock.patch.object(manager_utils, 'node_get_boot_mode', autospec=True) @mock.patch.object(manager_utils, 'node_set_boot_device', autospec=True) @mock.patch.object(dhcp_factory, 'DHCPFactory', autospec=True) diff --git a/setup.cfg b/setup.cfg index 827fc565ad..83d308e63a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -85,6 +85,7 @@ ironic.hardware.interfaces.console = no-console = ironic.drivers.modules.noop:NoConsole ironic.hardware.interfaces.deploy = + anaconda = ironic.drivers.modules.pxe:PXEAnacondaDeploy ansible = ironic.drivers.modules.ansible.deploy:AnsibleDeploy direct = ironic.drivers.modules.agent:AgentDeploy fake = ironic.drivers.modules.fake:FakeDeploy