From: Xu Han Peng Date: Fri, 11 Jul 2014 07:30:00 +0000 (+0800) Subject: Support Stateful and Stateless DHCPv6 by dnsmasq X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=3686d035ded94eadab6a3268e4b0f0cca11a22f8;p=openstack-build%2Fneutron-build.git Support Stateful and Stateless DHCPv6 by dnsmasq * This patch adds support for subnets created with 'ipv6_address_mode' set to 'dhcpv6-stateful' or 'dhcpv6-stateless' by dnsmasq. * If no dnsmasq process for subnet's network is launched, Neutron will launch new dnsmasq process on subnet's dhcp port in 'qdhcp-' namespace. If previous dnsmasq process is already launched, restart dnsmasq with new configuration. * Neutron will update dnsmasq process and restart it when subnet gets updated. * This patch enforces the version check of dnsmasq. dhcp-agent will fail to start if version of dnsmasq<2.63. DocImpact UpgradeImpact Blueprint dnsmasq-ipv6-dhcpv6-stateful Blueprint dnsmasq-ipv6-dhcpv6-stateless Change-Id: I30e9950bbc5a89f01ccb9c561471f155a9fd1d11 --- diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index c4b39ab78..59452732e 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -313,10 +313,11 @@ class Dnsmasq(DhcpLocalProcess): ver = re.findall("\d+.\d+", out)[0] is_valid_version = float(ver) >= cls.MINIMUM_VERSION if not is_valid_version: - LOG.warning(_('FAILED VERSION REQUIREMENT FOR DNSMASQ. ' - 'DHCP AGENT MAY NOT RUN CORRECTLY! ' - 'Please ensure that its version is %s ' - 'or above!'), cls.MINIMUM_VERSION) + LOG.error(_('FAILED VERSION REQUIREMENT FOR DNSMASQ. ' + 'DHCP AGENT MAY NOT RUN CORRECTLY! ' + 'Please ensure that its version is %s ' + 'or above!'), cls.MINIMUM_VERSION) + raise SystemExit(1) except (OSError, RuntimeError, IndexError, ValueError): LOG.error(_('Unable to determine dnsmasq version. ' 'Please ensure that its version is %s ' @@ -368,17 +369,12 @@ class Dnsmasq(DhcpLocalProcess): else: # Note(scollins) If the IPv6 attributes are not set, set it as # static to preserve previous behavior - if (not getattr(subnet, 'ipv6_ra_mode', None) and - not getattr(subnet, 'ipv6_address_mode', None)): + addr_mode = getattr(subnet, 'ipv6_address_mode', None) + ra_mode = getattr(subnet, 'ipv6_ra_mode', None) + if (addr_mode in [constants.DHCPV6_STATEFUL, + constants.DHCPV6_STATELESS] or + not addr_mode and not ra_mode): mode = 'static' - elif getattr(subnet, 'ipv6_ra_mode', None) is None: - # RA mode is not set - do not launch dnsmasq - continue - - if self.version >= self.MINIMUM_VERSION: - set_tag = 'set:' - else: - set_tag = '' cidr = netaddr.IPNetwork(subnet.cidr) @@ -390,14 +386,9 @@ class Dnsmasq(DhcpLocalProcess): # mode is optional and is not set - skip it if mode: cmd.append('--dhcp-range=%s%s,%s,%s,%s' % - (set_tag, self._TAG_PREFIX % i, + ('set:', self._TAG_PREFIX % i, cidr.network, mode, lease)) - else: - cmd.append('--dhcp-range=%s%s,%s,%s' % - (set_tag, self._TAG_PREFIX % i, - cidr.network, lease)) - - possible_leases += cidr.size + possible_leases += cidr.size # Cap the limit because creating lots of subnets can inflate # this possible lease cap. @@ -465,9 +456,8 @@ class Dnsmasq(DhcpLocalProcess): # associated with the subnet being managed by this # dhcp agent if alloc.subnet_id in v6_nets: - ra_mode = v6_nets[alloc.subnet_id].ipv6_ra_mode addr_mode = v6_nets[alloc.subnet_id].ipv6_address_mode - if (ra_mode is None and addr_mode == constants.IPV6_SLAAC): + if addr_mode != constants.DHCPV6_STATEFUL: continue hostname = 'host-%s' % alloc.ip_address.replace( '.', '-').replace(':', '-') @@ -497,7 +487,6 @@ class Dnsmasq(DhcpLocalProcess): LOG.debug(_('Building host file: %s'), filename) for (port, alloc, hostname, name) in self._iter_hosts(): - set_tag = '' # (dzyu) Check if it is legal ipv6 address, if so, need wrap # it with '[]' to let dnsmasq to distinguish MAC address from # IPv6 address. @@ -510,12 +499,9 @@ class Dnsmasq(DhcpLocalProcess): "ip": ip_address}) if getattr(port, 'extra_dhcp_opts', False): - if self.version >= self.MINIMUM_VERSION: - set_tag = 'set:' - buf.write('%s,%s,%s,%s%s\n' % (port.mac_address, name, ip_address, - set_tag, port.id)) + 'set:', port.id)) else: buf.write('%s,%s,%s\n' % (port.mac_address, name, ip_address)) @@ -575,17 +561,27 @@ class Dnsmasq(DhcpLocalProcess): dhcp_ips = collections.defaultdict(list) subnet_idx_map = {} for i, subnet in enumerate(self.network.subnets): - if not subnet.enable_dhcp: + if (not subnet.enable_dhcp or + (subnet.ip_version == 6 and + getattr(subnet, 'ipv6_address_mode', None) + in [None, constants.IPV6_SLAAC])): continue if subnet.dns_nameservers: options.append( - self._format_option(i, 'dns-server', - ','.join(subnet.dns_nameservers))) + self._format_option( + subnet.ip_version, i, 'dns-server', + ','.join( + Dnsmasq._convert_to_literal_addrs( + subnet.ip_version, subnet.dns_nameservers)))) else: # use the dnsmasq ip as nameservers only if there is no # dns-server submitted by the server subnet_idx_map[subnet.id] = i + if self.conf.dhcp_domain and subnet.ip_version == 6: + options.append('tag:tag%s,option6:domain-search,%s' % + (i, ''.join(self.conf.dhcp_domain))) + gateway = subnet.gateway_ip host_routes = [] for hr in subnet.host_routes: @@ -603,27 +599,42 @@ class Dnsmasq(DhcpLocalProcess): '%s/32,%s' % (METADATA_DEFAULT_IP, subnet_dhcp_ip) ) - if host_routes: - if gateway and subnet.ip_version == 4: - host_routes.append("%s,%s" % ("0.0.0.0/0", gateway)) - options.append( - self._format_option(i, 'classless-static-route', - ','.join(host_routes))) - options.append( - self._format_option(i, WIN2k3_STATIC_DNS, - ','.join(host_routes))) - if subnet.ip_version == 4: + if host_routes: + if gateway: + host_routes.append("%s,%s" % ("0.0.0.0/0", gateway)) + options.append( + self._format_option(subnet.ip_version, i, + 'classless-static-route', + ','.join(host_routes))) + options.append( + self._format_option(subnet.ip_version, i, + WIN2k3_STATIC_DNS, + ','.join(host_routes))) + if gateway: - options.append(self._format_option(i, 'router', gateway)) + options.append(self._format_option(subnet.ip_version, + i, 'router', + gateway)) else: - options.append(self._format_option(i, 'router')) + options.append(self._format_option(subnet.ip_version, + i, 'router')) for port in self.network.ports: if getattr(port, 'extra_dhcp_opts', False): - options.extend( - self._format_option(port.id, opt.opt_name, opt.opt_value) - for opt in port.extra_dhcp_opts) + for ip_version in (4, 6): + if any( + netaddr.IPAddress(ip.ip_address).version == ip_version + for ip in port.fixed_ips): + options.extend( + # TODO(xuhanp):Instead of applying extra_dhcp_opts + # to both DHCPv4 and DHCPv6, we need to find a new + # way to specify options for v4 and v6 + # respectively. We also need to validate the option + # before applying it. + self._format_option(ip_version, port.id, + opt.opt_name, opt.opt_value) + for opt in port.extra_dhcp_opts) # provides all dnsmasq ip as dns-server if there is more than # one dnsmasq for a subnet and there is no dns-server submitted @@ -636,10 +647,16 @@ class Dnsmasq(DhcpLocalProcess): dhcp_ips[i].append(ip.ip_address) for i, ips in dhcp_ips.items(): - if len(ips) > 1: - options.append(self._format_option(i, - 'dns-server', - ','.join(ips))) + for ip_version in (4, 6): + vx_ips = [ip for ip in ips + if netaddr.IPAddress(ip).version == ip_version] + if vx_ips: + options.append( + self._format_option( + ip_version, i, 'dns-server', + ','.join( + Dnsmasq._convert_to_literal_addrs(ip_version, + vx_ips)))) name = self.get_conf_file_name('opts') utils.replace_file(name, '\n'.join(options)) @@ -667,22 +684,26 @@ class Dnsmasq(DhcpLocalProcess): return retval - def _format_option(self, tag, option, *args): + def _format_option(self, ip_version, tag, option, *args): """Format DHCP option by option name or code.""" - if self.version >= self.MINIMUM_VERSION: - set_tag = 'tag:' - else: - set_tag = '' - option = str(option) if isinstance(tag, int): tag = self._TAG_PREFIX % tag if not option.isdigit(): - option = 'option:%s' % option + if ip_version == 4: + option = 'option:%s' % option + else: + option = 'option6:%s' % option + + return ','.join(('tag:' + tag, '%s' % option) + args) - return ','.join((set_tag + tag, '%s' % option) + args) + @staticmethod + def _convert_to_literal_addrs(ip_version, ips): + if ip_version == 4: + return ips + return ['[' + ip + ']' for ip in ips] def _enable_metadata(self, subnet): '''Determine if the metadata route will be pushed to hosts on subnet. diff --git a/neutron/tests/unit/test_linux_dhcp.py b/neutron/tests/unit/test_linux_dhcp.py index 98f06decf..cbe345ee3 100644 --- a/neutron/tests/unit/test_linux_dhcp.py +++ b/neutron/tests/unit/test_linux_dhcp.py @@ -17,6 +17,7 @@ import contextlib import os import mock +import netaddr from oslo.config import cfg import testtools @@ -60,8 +61,8 @@ class FakePort2: id = 'ffffffff-ffff-ffff-ffff-ffffffffffff' admin_state_up = False device_owner = 'foo2' - fixed_ips = [FakeIPAllocation('fdca:3ba5:a17a:4ba3::2', - 'ffffffff-ffff-ffff-ffff-ffffffffffff')] + fixed_ips = [FakeIPAllocation('192.168.0.3', + 'dddddddd-dddd-dddd-dddd-dddddddddddd')] mac_address = '00:00:f3:aa:bb:cc' def __init__(self): @@ -72,10 +73,10 @@ class FakePort3: id = '44444444-4444-4444-4444-444444444444' admin_state_up = True device_owner = 'foo3' - fixed_ips = [FakeIPAllocation('192.168.0.3', + fixed_ips = [FakeIPAllocation('192.168.0.4', 'dddddddd-dddd-dddd-dddd-dddddddddddd'), - FakeIPAllocation('fdca:3ba5:a17a:4ba3::3', - 'ffffffff-ffff-ffff-ffff-ffffffffffff')] + FakeIPAllocation('192.168.1.2', + 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee')] mac_address = '00:00:0f:aa:bb:cc' def __init__(self): @@ -95,6 +96,32 @@ class FakePort4: self.extra_dhcp_opts = [] +class FakeV6Port: + id = 'hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh' + admin_state_up = True + device_owner = 'foo3' + fixed_ips = [FakeIPAllocation('fdca:3ba5:a17a:4ba3::2', + 'ffffffff-ffff-ffff-ffff-ffffffffffff')] + mac_address = '00:00:f3:aa:bb:cc' + + def __init__(self): + self.extra_dhcp_opts = [] + + +class FakeDualPort: + id = 'hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh' + admin_state_up = True + device_owner = 'foo3' + fixed_ips = [FakeIPAllocation('192.168.0.3', + 'dddddddd-dddd-dddd-dddd-dddddddddddd'), + FakeIPAllocation('fdca:3ba5:a17a:4ba3::3', + 'ffffffff-ffff-ffff-ffff-ffffffffffff')] + mac_address = '00:00:0f:aa:bb:cc' + + def __init__(self): + self.extra_dhcp_opts = [] + + class FakeRouterPort: id = 'rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr' admin_state_up = True @@ -224,6 +251,18 @@ class FakeV4SubnetNoDHCP: dns_nameservers = [] +class FakeV6SubnetDHCPStateful: + id = 'ffffffff-ffff-ffff-ffff-ffffffffffff' + ip_version = 6 + cidr = 'fdca:3ba5:a17a:4ba3::/64' + gateway_ip = 'fdca:3ba5:a17a:4ba3::1' + enable_dhcp = True + host_routes = [FakeV6HostRoute] + dns_nameservers = ['2001:0200:feed:7ac0::1'] + ipv6_ra_mode = None + ipv6_address_mode = constants.DHCPV6_STATEFUL + + class FakeV6SubnetSlaac: id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee' ip_version = 6 @@ -271,14 +310,14 @@ class FakeV6Network: class FakeDualNetwork: id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' - subnets = [FakeV4Subnet(), FakeV6Subnet()] - ports = [FakePort1(), FakePort2(), FakePort3(), FakeRouterPort()] + subnets = [FakeV4Subnet(), FakeV6SubnetDHCPStateful()] + ports = [FakePort1(), FakeV6Port(), FakeDualPort(), FakeRouterPort()] namespace = 'qdhcp-ns' class FakeDualNetworkGatewayRoute: id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' - subnets = [FakeV4SubnetGatewayRoute(), FakeV6Subnet()] + subnets = [FakeV4SubnetGatewayRoute(), FakeV6SubnetDHCPStateful()] ports = [FakePort1(), FakePort2(), FakePort3(), FakeRouterPort()] namespace = 'qdhcp-ns' @@ -724,11 +763,16 @@ class TestDnsmasq(TestBase): prefix = '--dhcp-range=set:tag%d,%s,static,%s%s' else: prefix = '--dhcp-range=set:tag%d,%s,%s%s' - expected.extend(prefix % - (i, s.cidr.split('/')[0], lease_duration, seconds) - for i, s in enumerate(network.subnets)) - - expected.append('--dhcp-lease-max=%d' % max_leases) + possible_leases = 0 + for i, s in enumerate(network.subnets): + if (s.ip_version != 6 + or s.ipv6_address_mode == constants.DHCPV6_STATEFUL): + expected.extend([prefix % ( + i, s.cidr.split('/')[0], lease_duration, seconds)]) + possible_leases += netaddr.IPNetwork(s.cidr).size + + expected.append('--dhcp-lease-max=%d' % min( + possible_leases, max_leases)) expected.extend(extra_options) self.execute.return_value = ('', '') @@ -775,10 +819,9 @@ class TestDnsmasq(TestBase): self.safe.assert_has_calls([mock.call(exp_host_name, exp_host_data), mock.call(exp_addn_name, exp_addn_data)]) - def test_spawn_no_dnsmasq_ipv6_mode(self): + def test_spawn_no_dhcp_range(self): network = FakeV6Network() - subnet = FakeV6Subnet() - subnet.ipv6_ra_mode = True + subnet = FakeV6SubnetSlaac() network.subnets = [subnet] self._test_spawn(['--conf-file=', '--domain=openstacklocal'], network, has_static=False) @@ -805,18 +848,15 @@ class TestDnsmasq(TestBase): def test_output_opts_file(self): fake_v6 = '2001:0200:feed:7ac0::1' - fake_v6_cidr = '2001:0200:feed:7ac0::/64' expected = ( 'tag:tag0,option:dns-server,8.8.8.8\n' 'tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1,' '0.0.0.0/0,192.168.0.1\n' 'tag:tag0,249,20.0.0.1/24,20.0.0.1,0.0.0.0/0,192.168.0.1\n' 'tag:tag0,option:router,192.168.0.1\n' - 'tag:tag1,option:dns-server,%s\n' - 'tag:tag1,option:classless-static-route,%s,%s\n' - 'tag:tag1,249,%s,%s').lstrip() % (fake_v6, - fake_v6_cidr, fake_v6, - fake_v6_cidr, fake_v6) + 'tag:tag1,option6:dns-server,%s\n' + 'tag:tag1,option6:domain-search,openstacklocal').lstrip() % ( + '[' + fake_v6 + ']') with mock.patch.object(dhcp.Dnsmasq, 'get_conf_file_name') as conf_fn: conf_fn.return_value = '/foo/opts' @@ -828,15 +868,12 @@ class TestDnsmasq(TestBase): def test_output_opts_file_gateway_route(self): fake_v6 = '2001:0200:feed:7ac0::1' - fake_v6_cidr = '2001:0200:feed:7ac0::/64' expected = """ tag:tag0,option:dns-server,8.8.8.8 tag:tag0,option:router,192.168.0.1 -tag:tag1,option:dns-server,%s -tag:tag1,option:classless-static-route,%s,%s -tag:tag1,249,%s,%s""".lstrip() % (fake_v6, - fake_v6_cidr, fake_v6, - fake_v6_cidr, fake_v6) +tag:tag1,option6:dns-server,%s +tag:tag1,option6:domain-search,openstacklocal""".lstrip() % ( + '[' + fake_v6 + ']') with mock.patch.object(dhcp.Dnsmasq, 'get_conf_file_name') as conf_fn: conf_fn.return_value = '/foo/opts' @@ -885,21 +922,6 @@ tag:tag0,option:router,192.168.0.1""".lstrip() self.safe.assert_called_once_with('/foo/opts', expected) - def test_output_opts_file_single_dhcp_ver2_48(self): - expected = ( - 'tag0,option:dns-server,8.8.8.8\n' - 'tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1,' - '0.0.0.0/0,192.168.0.1\n' - 'tag0,249,20.0.0.1/24,20.0.0.1,0.0.0.0/0,192.168.0.1\n' - 'tag0,option:router,192.168.0.1').lstrip() - with mock.patch.object(dhcp.Dnsmasq, 'get_conf_file_name') as conf_fn: - conf_fn.return_value = '/foo/opts' - dm = dhcp.Dnsmasq(self.conf, FakeDualNetworkSingleDHCP(), - version=float(2.48)) - dm._output_opts_file() - - self.safe.assert_called_once_with('/foo/opts', expected) - def test_output_opts_file_no_gateway(self): expected = """ tag:tag0,option:classless-static-route,169.254.169.254/32,192.168.1.1 @@ -997,42 +1019,6 @@ tag:tag0,option:router""".lstrip() self.safe.assert_called_once_with('/foo/opts', expected) - def test_output_opts_file_pxe_3port_1net_diff_details(self): - expected = ( - 'tag:tag0,option:dns-server,8.8.8.8\n' - 'tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1,' - '0.0.0.0/0,192.168.0.1\n' - 'tag:tag0,249,20.0.0.1/24,20.0.0.1,0.0.0.0/0,192.168.0.1\n' - 'tag:tag0,option:router,192.168.0.1\n' - 'tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,' - 'option:tftp-server,192.168.0.3\n' - 'tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,' - 'option:server-ip-address,192.168.0.2\n' - 'tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,' - 'option:bootfile-name,pxelinux.0\n' - 'tag:ffffffff-ffff-ffff-ffff-ffffffffffff,' - 'option:tftp-server,192.168.0.5\n' - 'tag:ffffffff-ffff-ffff-ffff-ffffffffffff,' - 'option:server-ip-address,192.168.0.5\n' - 'tag:ffffffff-ffff-ffff-ffff-ffffffffffff,' - 'option:bootfile-name,pxelinux2.0\n' - 'tag:44444444-4444-4444-4444-444444444444,' - 'option:tftp-server,192.168.0.7\n' - 'tag:44444444-4444-4444-4444-444444444444,' - 'option:server-ip-address,192.168.0.7\n' - 'tag:44444444-4444-4444-4444-444444444444,' - 'option:bootfile-name,pxelinux3.0') - expected = expected.lstrip() - - with mock.patch.object(dhcp.Dnsmasq, 'get_conf_file_name') as conf_fn: - conf_fn.return_value = '/foo/opts' - dm = dhcp.Dnsmasq(self.conf, - FakeV4NetworkPxe3Ports("portsDifferent"), - version=dhcp.Dnsmasq.MINIMUM_VERSION) - dm._output_opts_file() - - self.safe.assert_called_once_with('/foo/opts', expected) - def test_output_opts_file_pxe_3port_2net(self): expected = ( 'tag:tag0,option:dns-server,8.8.8.8\n' @@ -1131,18 +1117,15 @@ tag:tag0,option:router""".lstrip() ).lstrip() exp_opt_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/opts' fake_v6 = '2001:0200:feed:7ac0::1' - fake_v6_cidr = '2001:0200:feed:7ac0::/64' exp_opt_data = ( 'tag:tag0,option:dns-server,8.8.8.8\n' 'tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1,' '0.0.0.0/0,192.168.0.1\n' 'tag:tag0,249,20.0.0.1/24,20.0.0.1,0.0.0.0/0,192.168.0.1\n' 'tag:tag0,option:router,192.168.0.1\n' - 'tag:tag1,option:dns-server,%s\n' - 'tag:tag1,option:classless-static-route,%s,%s\n' - 'tag:tag1,249,%s,%s').lstrip() % (fake_v6, - fake_v6_cidr, fake_v6, - fake_v6_cidr, fake_v6) + 'tag:tag1,option6:dns-server,%s\n' + 'tag:tag1,option6:domain-search,openstacklocal').lstrip() % ( + '[' + fake_v6 + ']') return (exp_host_name, exp_host_data, exp_addn_name, exp_addn_data, exp_opt_name, exp_opt_data,) @@ -1333,8 +1316,8 @@ tag:tag0,option:router""".lstrip() float(2.65)) def test_check_fail_version(self): - self._check_version('Dnsmasq version 2.48 Copyright (c)...', - float(2.48)) + with testtools.ExpectedException(SystemExit): + self._check_version('Dnsmasq version 2.62 Copyright (c)...', 0) def test_check_version_failed_cmd_execution(self): with testtools.ExpectedException(SystemExit):