Merge "Implement a DHCP driver backed by dnsmasq"
This commit is contained in:
commit
0773a80f91
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
43
ironic/conf/dnsmasq.py
Normal 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')
|
@ -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
159
ironic/dhcp/dnsmasq.py
Normal 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
|
@ -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
|
||||
|
@ -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 image_service as base_image_service
|
||||
@ -45,6 +46,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):
|
||||
@ -674,7 +680,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')
|
||||
@ -898,7 +904,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}
|
||||
]
|
||||
@ -1904,7 +1910,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
|
||||
@ -1932,7 +1939,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}]
|
||||
|
||||
@ -1940,7 +1947,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',
|
||||
@ -1963,7 +1971,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}]
|
||||
|
||||
|
140
ironic/tests/unit/dhcp/test_dnsmasq.py
Normal file
140
ironic/tests/unit/dhcp/test_dnsmasq.py
Normal 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))
|
7
releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml
Normal file
7
releasenotes/notes/dnsmasq_dhcp-9154fcae927dc3de.yaml
Normal 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.
|
Loading…
Reference in New Issue
Block a user