summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSteve Baker <sbaker@redhat.com>2022-07-28 15:09:31 +1200
committerSteve Baker <sbaker@redhat.com>2022-09-05 13:57:39 +1200
commit754e6bb6629a87d52304d736261860683d37da3f (patch)
treee5667ff5f11c8e42c19621ec5f42ed1c5badea8d
parent4d5c60650efb3fd3af3179d34d2684617a7780ee (diff)
downloadironic-754e6bb6629a87d52304d736261860683d37da3f.tar.gz
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
-rw-r--r--ironic/common/pxe_utils.py7
-rw-r--r--ironic/conf/__init__.py2
-rw-r--r--ironic/conf/dhcp.py3
-rw-r--r--ironic/conf/dnsmasq.py43
-rw-r--r--ironic/dhcp/base.py11
-rw-r--r--ironic/dhcp/dnsmasq.py159
-rw-r--r--ironic/dhcp/neutron.py11
-rw-r--r--ironic/tests/unit/common/test_pxe_utils.py20
-rw-r--r--ironic/tests/unit/dhcp/test_dnsmasq.py140
-rw-r--r--releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml7
-rw-r--r--setup.cfg1
11 files changed, 394 insertions, 10 deletions
diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py
index 88c55d6d7..ad51bac97 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 4e4b7bf7a..ad1ba227c 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 2c58529fd..17a937f7d 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 000000000..f1ba1de23
--- /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 57a4e7911..b2b711307 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 000000000..c6f27afe4
--- /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 a5cb09282..06962ad42 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 c6dc9bffa..6b1339894 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 000000000..64fe46f33
--- /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 000000000..bbf7dad40
--- /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 9b4366a84..8354ae8cc 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