From 754e6bb6629a87d52304d736261860683d37da3f Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Thu, 28 Jul 2022 15:09:31 +1200 Subject: [PATCH] Implement a DHCP driver backed by dnsmasq The ``[dhcp]dhcp_provider`` configuration option can now be set to ``dnsmasq`` as an alternative to ``none`` for standalone deployments. This enables the same node-specific DHCP capabilities as the ``neutron`` provider. See the ``[dnsmasq]`` section for configuration options. Change-Id: I3ab86ed68c6597d4fb4b0f2ae6d4fc34b1d59f11 Story: 2010203 Task: 45922 --- ironic/common/pxe_utils.py | 7 +- ironic/conf/__init__.py | 2 + ironic/conf/dhcp.py | 3 +- ironic/conf/dnsmasq.py | 43 +++++ ironic/dhcp/base.py | 11 ++ ironic/dhcp/dnsmasq.py | 159 ++++++++++++++++++ ironic/dhcp/neutron.py | 11 ++ ironic/tests/unit/common/test_pxe_utils.py | 20 ++- ironic/tests/unit/dhcp/test_dnsmasq.py | 140 +++++++++++++++ .../notes/dnsmasq_dhcp-9154fcae927dc3de.yaml | 7 + setup.cfg | 1 + 11 files changed, 394 insertions(+), 10 deletions(-) create mode 100644 ironic/conf/dnsmasq.py create mode 100644 ironic/dhcp/dnsmasq.py create mode 100644 ironic/tests/unit/dhcp/test_dnsmasq.py create mode 100644 releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py index 88c55d6d7d..ad51bac971 100644 --- a/ironic/common/pxe_utils.py +++ b/ironic/common/pxe_utils.py @@ -59,6 +59,7 @@ DHCPV6_BOOTFILE_NAME = '59' # rfc5970 DHCP_TFTP_SERVER_ADDRESS = '150' # rfc5859 DHCP_IPXE_ENCAP_OPTS = '175' # Tentatively Assigned DHCP_TFTP_PATH_PREFIX = '210' # rfc5071 +DHCP_SERVER_IP_ADDRESS = '255' # dnsmasq server-ip-address DEPLOY_KERNEL_RAMDISK_LABELS = ['deploy_kernel', 'deploy_ramdisk'] RESCUE_KERNEL_RAMDISK_LABELS = ['rescue_kernel', 'rescue_ramdisk'] @@ -488,7 +489,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False, else: use_ip_version = int(CONF.pxe.ip_version) dhcp_opts = [] - dhcp_provider_name = CONF.dhcp.dhcp_provider + api = dhcp_factory.DHCPFactory().provider if use_ip_version == 4: boot_file_param = DHCP_BOOTFILE_NAME else: @@ -517,7 +518,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False, ipxe_script_url = '/'.join([CONF.deploy.http_url, script_name]) # if the request comes from dumb firmware send them the iPXE # boot image. - if dhcp_provider_name == 'neutron': + if api.supports_ipxe_tag(): # Neutron use dnsmasq as default DHCP agent. Neutron carries the # configuration to relate to the tags below. The ipxe6 tag was # added in the Stein cycle which identifies the iPXE User-Class @@ -588,7 +589,7 @@ def dhcp_options_for_instance(task, ipxe_enabled=False, url_boot=False, # Related bug was opened on Neutron side: # https://bugs.launchpad.net/neutron/+bug/1723354 if not url_boot: - dhcp_opts.append({'opt_name': 'server-ip-address', + dhcp_opts.append({'opt_name': DHCP_SERVER_IP_ADDRESS, 'opt_value': CONF.pxe.tftp_server}) # Append the IP version for all the configuration options diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index 4e4b7bf7ad..ad1ba227c7 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -27,6 +27,7 @@ from ironic.conf import database from ironic.conf import default from ironic.conf import deploy from ironic.conf import dhcp +from ironic.conf import dnsmasq from ironic.conf import drac from ironic.conf import glance from ironic.conf import healthcheck @@ -62,6 +63,7 @@ default.register_opts(CONF) deploy.register_opts(CONF) drac.register_opts(CONF) dhcp.register_opts(CONF) +dnsmasq.register_opts(CONF) glance.register_opts(CONF) healthcheck.register_opts(CONF) ibmc.register_opts(CONF) diff --git a/ironic/conf/dhcp.py b/ironic/conf/dhcp.py index 2c58529fd6..17a937f7d5 100644 --- a/ironic/conf/dhcp.py +++ b/ironic/conf/dhcp.py @@ -20,7 +20,8 @@ from ironic.common.i18n import _ opts = [ cfg.StrOpt('dhcp_provider', default='neutron', - help=_('DHCP provider to use. "neutron" uses Neutron, and ' + help=_('DHCP provider to use. "neutron" uses Neutron, ' + '"dnsmasq" uses the Dnsmasq provider, and ' '"none" uses a no-op provider.')), ] diff --git a/ironic/conf/dnsmasq.py b/ironic/conf/dnsmasq.py new file mode 100644 index 0000000000..f1ba1de239 --- /dev/null +++ b/ironic/conf/dnsmasq.py @@ -0,0 +1,43 @@ +# +# Copyright 2022 Red Hat, Inc. +# +# 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 oslo_config import cfg + +from ironic.common.i18n import _ + +opts = [ + cfg.StrOpt('dhcp_optsdir', + default='/etc/dnsmasq.d/optsdir.d', + help=_('Directory where the "dnsmasq" provider will write ' + 'option configuration files for an external ' + 'Dnsmasq to read. Use the same path for the ' + 'dhcp-optsdir dnsmasq configuration directive.')), + cfg.StrOpt('dhcp_hostsdir', + default='/etc/dnsmasq.d/hostsdir.d', + help=_('Directory where the "dnsmasq" provider will write ' + 'host configuration files for an external ' + 'Dnsmasq to read. Use the same path for the ' + 'dhcp-hostsdir dnsmasq configuration directive.')), + cfg.StrOpt('dhcp_leasefile', + default='/var/lib/dnsmasq/dnsmasq.leases', + help=_('Dnsmasq leases file for the "dnsmasq" driver to ' + 'discover IP addresses of managed nodes. Use the' + 'same path for the dhcp-leasefile dnsmasq ' + 'configuration directive.')), +] + + +def register_opts(conf): + conf.register_opts(opts, group='dnsmasq') diff --git a/ironic/dhcp/base.py b/ironic/dhcp/base.py index 57a4e79111..b2b711307d 100644 --- a/ironic/dhcp/base.py +++ b/ironic/dhcp/base.py @@ -102,3 +102,14 @@ class BaseDHCP(object, metaclass=abc.ABCMeta): :raises: FailedToCleanDHCPOpts """ pass + + def supports_ipxe_tag(self): + """Whether the provider will correctly apply the 'ipxe' tag. + + When iPXE makes a DHCP request, does this provider support adding + the tag `ipxe` or `ipxe6` (for IPv6). When the provider returns True, + options can be added which filter on these tags. + + :returns: True when the driver supports tagging iPXE DHCP requests + """ + return False diff --git a/ironic/dhcp/dnsmasq.py b/ironic/dhcp/dnsmasq.py new file mode 100644 index 0000000000..c6f27afe46 --- /dev/null +++ b/ironic/dhcp/dnsmasq.py @@ -0,0 +1,159 @@ +# +# Copyright 2022 Red Hat, Inc. +# +# 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_log import log as logging +from oslo_utils import uuidutils + +from ironic.conf import CONF +from ironic.dhcp import base + +LOG = logging.getLogger(__name__) + + +class DnsmasqDHCPApi(base.BaseDHCP): + """API for managing host specific Dnsmasq configuration.""" + + def update_port_dhcp_opts(self, port_id, dhcp_options, token=None, + context=None): + pass + + def update_dhcp_opts(self, task, options, vifs=None): + """Send or update the DHCP BOOT options for this node. + + :param task: A TaskManager instance. + :param options: this will be a list of dicts, e.g. + + :: + + [{'opt_name': '67', + 'opt_value': 'pxelinux.0', + 'ip_version': 4}, + {'opt_name': '66', + 'opt_value': '123.123.123.456', + 'ip_version': 4}] + :param vifs: Ignored argument + """ + node = task.node + macs = set(self._pxe_enabled_macs(task.ports)) + + opt_file = self._opt_file_path(node) + tag = node.driver_internal_info.get('dnsmasq_tag') + if not tag: + tag = uuidutils.generate_uuid() + node.set_driver_internal_info('dnsmasq_tag', tag) + node.save() + + LOG.debug('Writing to %s:', opt_file) + with open(opt_file, 'w') as f: + # Apply each option by tag + for option in options: + entry = 'tag:{tag},{opt_name},{opt_value}\n'.format( + tag=tag, + opt_name=option.get('opt_name'), + opt_value=option.get('opt_value'), + ) + LOG.debug(entry) + f.write(entry) + + for mac in macs: + host_file = self._host_file_path(mac) + LOG.debug('Writing to %s:', host_file) + with open(host_file, 'w') as f: + # Tag each address with the unique uuid scoped to + # this node and DHCP transaction + entry = '{mac},set:{tag},set:ironic\n'.format( + mac=mac, tag=tag) + LOG.debug(entry) + f.write(entry) + + def _opt_file_path(self, node): + return os.path.join(CONF.dnsmasq.dhcp_optsdir, + 'ironic-{}.conf'.format(node.uuid)) + + def _host_file_path(self, mac): + return os.path.join(CONF.dnsmasq.dhcp_hostsdir, + 'ironic-{}.conf'.format(mac)) + + def _pxe_enabled_macs(self, ports): + for port in ports: + if port.pxe_enabled: + yield port.address + + def get_ip_addresses(self, task): + """Get IP addresses for all ports/portgroups in `task`. + + :param task: a TaskManager instance. + :returns: List of IP addresses associated with + task's ports/portgroups. + """ + lease_path = CONF.dnsmasq.dhcp_leasefile + macs = set(self._pxe_enabled_macs(task.ports)) + addresses = [] + with open(lease_path, 'r') as f: + for line in f.readlines(): + lease = line.split() + if lease[1] in macs: + addresses.append(lease[2]) + LOG.debug('Found addresses for %s: %s', + task.node.uuid, ', '.join(addresses)) + return addresses + + def clean_dhcp_opts(self, task): + """Clean up the DHCP BOOT options for the host in `task`. + + :param task: A TaskManager instance. + + :raises: FailedToCleanDHCPOpts + """ + + node = task.node + # Discard this unique tag + node.del_driver_internal_info('dnsmasq_tag') + node.save() + + # Changing the host rule to ignore will be picked up by dnsmasq + # without requiring a SIGHUP. When the mac address is active again + # this file will be replaced with one that applies a new unique tag. + macs = set(self._pxe_enabled_macs(task.ports)) + for mac in macs: + host_file = self._host_file_path(mac) + with open(host_file, 'w') as f: + entry = '{mac},ignore\n'.format(mac=mac) + f.write(entry) + + # Deleting the file containing dhcp-option won't remove the rules from + # dnsmasq but no requests will be tagged with the dnsmasq_tag uuid so + # these rules will not apply. + opt_file = self._opt_file_path(node) + if os.path.exists(opt_file): + os.remove(opt_file) + + def supports_ipxe_tag(self): + """Whether the provider will correctly apply the 'ipxe' tag. + + When iPXE makes a DHCP request, does this provider support adding + the tag `ipxe` or `ipxe6` (for IPv6). When the provider returns True, + options can be added which filter on these tags. + + The `dnsmasq` provider sets this to True on the assumption that the + following is included in the dnsmasq.conf: + + dhcp-match=set:ipxe,175 + + :returns: True + """ + return True diff --git a/ironic/dhcp/neutron.py b/ironic/dhcp/neutron.py index a5cb092826..06962ad428 100644 --- a/ironic/dhcp/neutron.py +++ b/ironic/dhcp/neutron.py @@ -278,3 +278,14 @@ class NeutronDHCPApi(base.BaseDHCP): task, task.portgroups, client) return port_ip_addresses + portgroup_ip_addresses + + def supports_ipxe_tag(self): + """Whether the provider will correctly apply the 'ipxe' tag. + + When iPXE makes a DHCP request, does this provider support adding + the tag `ipxe` or `ipxe6` (for IPv6). When the provider returns True, + options can be added which filter on these tags. + + :returns: True + """ + return True diff --git a/ironic/tests/unit/common/test_pxe_utils.py b/ironic/tests/unit/common/test_pxe_utils.py index c6dc9bffab..6b1339894e 100644 --- a/ironic/tests/unit/common/test_pxe_utils.py +++ b/ironic/tests/unit/common/test_pxe_utils.py @@ -25,6 +25,7 @@ from oslo_config import cfg from oslo_utils import fileutils from oslo_utils import uuidutils +from ironic.common import dhcp_factory from ironic.common import exception from ironic.common.glance_service import image_service from ironic.common import pxe_utils @@ -44,6 +45,11 @@ DRV_INFO_DICT = db_utils.get_test_pxe_driver_info() DRV_INTERNAL_INFO_DICT = db_utils.get_test_pxe_driver_internal_info() +def _reset_dhcp_provider(config, provider_name): + config(dhcp_provider=provider_name, group='dhcp') + dhcp_factory.DHCPFactory._dhcp_provider = None + + # Prevent /httpboot validation on creating the node @mock.patch('ironic.drivers.modules.pxe.PXEBoot.__init__', lambda self: None) class TestPXEUtils(db_base.DbTestCase): @@ -673,7 +679,7 @@ class TestPXEUtils(db_base.DbTestCase): # TODO(TheJulia): We should... like... fix the template to # enable mac address usage..... grub_tmplte = "ironic/drivers/modules/pxe_grub_config.template" - self.config(dhcp_provider='none', group='dhcp') + _reset_dhcp_provider(self.config, 'none') self.config(tftp_root=tempfile.mkdtemp(), group='pxe') link_ip_configs_mock.side_effect = \ exception.FailedToGetIPAddressOnPort(port_id='blah') @@ -897,7 +903,7 @@ class TestPXEUtils(db_base.DbTestCase): {'opt_name': '150', 'opt_value': '192.0.2.1', 'ip_version': ip_version}, - {'opt_name': 'server-ip-address', + {'opt_name': '255', 'opt_value': '192.0.2.1', 'ip_version': ip_version} ] @@ -1838,7 +1844,8 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): self.config(tftp_server='ff80::1', group='pxe') self.config(http_url='http://[ff80::1]:1234', group='deploy') - self.config(dhcp_provider='isc', group='dhcp') + _reset_dhcp_provider(self.config, 'none') + if ip_version == 6: # NOTE(TheJulia): DHCPv6 RFCs seem to indicate that the prior # options are not imported, although they may be supported @@ -1866,7 +1873,7 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): {'opt_name': '67', 'opt_value': expected_boot_script_url, 'ip_version': ip_version}, - {'opt_name': 'server-ip-address', + {'opt_name': '255', 'opt_value': '192.0.2.1', 'ip_version': ip_version}] @@ -1874,7 +1881,8 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): pxe_utils.dhcp_options_for_instance( task, ipxe_enabled=True)) - self.config(dhcp_provider='neutron', group='dhcp') + _reset_dhcp_provider(self.config, 'neutron') + if ip_version == 6: # Boot URL variable set from prior test of isc parameters. expected_info = [{'opt_name': 'tag:!ipxe6,59', @@ -1897,7 +1905,7 @@ class iPXEBuildConfigOptionsTestCase(db_base.DbTestCase): {'opt_name': 'tag:ipxe,67', 'opt_value': expected_boot_script_url, 'ip_version': ip_version}, - {'opt_name': 'server-ip-address', + {'opt_name': '255', 'opt_value': '192.0.2.1', 'ip_version': ip_version}] diff --git a/ironic/tests/unit/dhcp/test_dnsmasq.py b/ironic/tests/unit/dhcp/test_dnsmasq.py new file mode 100644 index 0000000000..64fe46f339 --- /dev/null +++ b/ironic/tests/unit/dhcp/test_dnsmasq.py @@ -0,0 +1,140 @@ +# +# Copyright 2022 Red Hat, Inc. +# +# 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 +import tempfile + +from ironic.common import dhcp_factory +from ironic.common import utils as common_utils +from ironic.conductor import task_manager +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as object_utils + + +class TestDnsmasqDHCPApi(db_base.DbTestCase): + + def setUp(self): + super(TestDnsmasqDHCPApi, self).setUp() + self.config(dhcp_provider='dnsmasq', + group='dhcp') + self.node = object_utils.create_test_node(self.context) + + self.ports = [ + object_utils.create_test_port( + self.context, node_id=self.node.id, id=2, + uuid='1be26c0b-03f2-4d2e-ae87-c02d7f33c782', + address='52:54:00:cf:2d:32', + pxe_enabled=True)] + + self.optsdir = tempfile.mkdtemp() + self.addCleanup(lambda: common_utils.rmtree_without_raise( + self.optsdir)) + self.config(dhcp_optsdir=self.optsdir, group='dnsmasq') + + self.hostsdir = tempfile.mkdtemp() + self.addCleanup(lambda: common_utils.rmtree_without_raise( + self.hostsdir)) + self.config(dhcp_hostsdir=self.hostsdir, group='dnsmasq') + + dhcp_factory.DHCPFactory._dhcp_provider = None + self.api = dhcp_factory.DHCPFactory() + self.opts = [ + { + 'ip_version': 4, + 'opt_name': '67', + 'opt_value': 'bootx64.efi' + }, + { + 'ip_version': 4, + 'opt_name': '210', + 'opt_value': '/tftpboot/' + }, + { + 'ip_version': 4, + 'opt_name': '66', + 'opt_value': '192.0.2.135', + }, + { + 'ip_version': 4, + 'opt_name': '150', + 'opt_value': '192.0.2.135' + }, + { + 'ip_version': 4, + 'opt_name': '255', + 'opt_value': '192.0.2.135' + } + ] + + def test_update_dhcp(self): + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.api.update_dhcp(task, self.opts) + + dnsmasq_tag = task.node.driver_internal_info.get('dnsmasq_tag') + self.assertEqual(36, len(dnsmasq_tag)) + + hostfile = os.path.join(self.hostsdir, + 'ironic-52:54:00:cf:2d:32.conf') + with open(hostfile, 'r') as f: + self.assertEqual( + '52:54:00:cf:2d:32,set:%s,set:ironic\n' % dnsmasq_tag, + f.readline()) + + optsfile = os.path.join(self.optsdir, + 'ironic-%s.conf' % self.node.uuid) + with open(optsfile, 'r') as f: + self.assertEqual([ + 'tag:%s,67,bootx64.efi\n' % dnsmasq_tag, + 'tag:%s,210,/tftpboot/\n' % dnsmasq_tag, + 'tag:%s,66,192.0.2.135\n' % dnsmasq_tag, + 'tag:%s,150,192.0.2.135\n' % dnsmasq_tag, + 'tag:%s,255,192.0.2.135\n' % dnsmasq_tag], + f.readlines()) + + def test_get_ip_addresses(self): + with task_manager.acquire(self.context, + self.node.uuid) as task: + with tempfile.NamedTemporaryFile() as fp: + self.config(dhcp_leasefile=fp.name, group='dnsmasq') + fp.write(b"1659975057 52:54:00:cf:2d:32 192.0.2.198 * *\n") + fp.flush() + self.assertEqual( + ['192.0.2.198'], + self.api.provider.get_ip_addresses(task)) + + def test_clean_dhcp_opts(self): + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.api.update_dhcp(task, self.opts) + + hostfile = os.path.join(self.hostsdir, + 'ironic-52:54:00:cf:2d:32.conf') + optsfile = os.path.join(self.optsdir, + 'ironic-%s.conf' % self.node.uuid) + self.assertTrue(os.path.isfile(hostfile)) + self.assertTrue(os.path.isfile(optsfile)) + + with task_manager.acquire(self.context, + self.node.uuid) as task: + self.api.clean_dhcp(task) + + # assert the host file remains with the ignore directive, and the opts + # file is deleted + with open(hostfile, 'r') as f: + self.assertEqual( + '52:54:00:cf:2d:32,ignore\n', + f.readline()) + self.assertFalse(os.path.isfile(optsfile)) diff --git a/releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml b/releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml new file mode 100644 index 0000000000..bbf7dad40a --- /dev/null +++ b/releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + The ``[dhcp]dhcp_provider`` configuration option can now be set to + ``dnsmasq`` as an alternative to ``none`` for standalone deployments. This + enables the same node-specific DHCP capabilities as the ``neutron`` provider. + See the ``[dnsmasq]`` section for configuration options. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 9b4366a845..8354ae8ccb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,6 +52,7 @@ wsgi_scripts = ironic-api-wsgi = ironic.api.wsgi:initialize_wsgi_app ironic.dhcp = + dnsmasq = ironic.dhcp.dnsmasq:DnsmasqDHCPApi neutron = ironic.dhcp.neutron:NeutronDHCPApi none = ironic.dhcp.none:NoneDHCPApi