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
This commit is contained in:
Steve Baker 2022-07-28 15:09:31 +12:00
parent 4d5c60650e
commit 754e6bb662
11 changed files with 394 additions and 10 deletions

View File

@ -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

View File

@ -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)

View File

@ -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.')),
]

43
ironic/conf/dnsmasq.py Normal file
View File

@ -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')

View File

@ -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

159
ironic/dhcp/dnsmasq.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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}]

View File

@ -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))

View File

@ -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.

View File

@ -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