Support Extra DHCP Options for IPv4 and IPv6

Add API and DB change for Blueprint extra-dhcp-opts-ipv4-ipv6.
Add unit tests for this change.

The validation of input extra dhcp options is not included
in this commit. A follow-up commit will be added for
validation.

DocImpact
APIImpact

Change-Id: I346334568929e50e51dd577cde6a257f4bce8e77
Partially-implements: Blueprint extra-dhcp-opts-ipv4-ipv6
This commit is contained in:
Xu Han Peng 2014-10-23 16:24:26 +08:00
parent 64bd24f9d6
commit a42928780c
7 changed files with 296 additions and 105 deletions

View File

@ -31,7 +31,7 @@ from neutron.agent.linux import utils
from neutron.common import constants
from neutron.common import exceptions
from neutron.common import utils as commonutils
from neutron.i18n import _LE
from neutron.i18n import _LE, _LI
from neutron.openstack.common import log as logging
from neutron.openstack.common import uuidutils
@ -549,15 +549,19 @@ class Dnsmasq(DhcpLocalProcess):
def _output_opts_file(self):
"""Write a dnsmasq compatible options file."""
options, subnet_index_map = self._generate_opts_per_subnet()
options += self._generate_opts_per_port(subnet_index_map)
name = self.get_conf_file_name('opts')
utils.replace_file(name, '\n'.join(options))
return name
def _generate_opts_per_subnet(self):
options = []
subnet_index_map = {}
if self.conf.enable_isolated_metadata:
subnet_to_interface_ip = self._make_subnet_interface_ip_map()
options = []
isolated_subnets = self.get_isolated_subnets(self.network)
dhcp_ips = collections.defaultdict(list)
subnet_idx_map = {}
for i, subnet in enumerate(self.network.subnets):
if (not subnet.enable_dhcp or
(subnet.ip_version == 6 and
@ -574,7 +578,7 @@ class Dnsmasq(DhcpLocalProcess):
else:
# use the dnsmasq ip as nameservers only if there is no
# dns-server submitted by the server
subnet_idx_map[subnet.id] = i
subnet_index_map[subnet.id] = i
if self.conf.dhcp_domain and subnet.ip_version == 6:
options.append('tag:tag%s,option6:domain-search,%s' %
@ -619,29 +623,35 @@ class Dnsmasq(DhcpLocalProcess):
else:
options.append(self._format_option(subnet.ip_version,
i, 'router'))
return options, subnet_index_map
def _generate_opts_per_port(self, subnet_index_map):
options = []
dhcp_ips = collections.defaultdict(list)
for port in self.network.ports:
if getattr(port, 'extra_dhcp_opts', False):
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)
port_ip_versions = set(
[netaddr.IPAddress(ip.ip_address).version
for ip in port.fixed_ips])
for opt in port.extra_dhcp_opts:
opt_ip_version = opt.ip_version
if opt_ip_version in port_ip_versions:
options.append(
self._format_option(opt_ip_version, port.id,
opt.opt_name, opt.opt_value))
else:
LOG.info(_LI("Cannot apply dhcp option %(opt)s "
"because it's ip_version %(version)d "
"is not in port's address IP versions"),
{'opt': opt.opt_name,
'version': opt_ip_version})
# provides all dnsmasq ip as dns-server if there is more than
# one dnsmasq for a subnet and there is no dns-server submitted
# by the server
if port.device_owner == constants.DEVICE_OWNER_DHCP:
for ip in port.fixed_ips:
i = subnet_idx_map.get(ip.subnet_id)
i = subnet_index_map.get(ip.subnet_id)
if i is None:
continue
dhcp_ips[i].append(ip.ip_address)
@ -657,10 +667,7 @@ class Dnsmasq(DhcpLocalProcess):
','.join(
Dnsmasq._convert_to_literal_addrs(ip_version,
vx_ips))))
name = self.get_conf_file_name('opts')
utils.replace_file(name, '\n'.join(options))
return name
return options
def _make_subnet_interface_ip_map(self):
ip_dev = ip_lib.IPDevice(

View File

@ -39,9 +39,12 @@ class ExtraDhcpOpt(model_base.BASEV2, models_v2.HasId):
nullable=False)
opt_name = sa.Column(sa.String(64), nullable=False)
opt_value = sa.Column(sa.String(255), nullable=False)
__table_args__ = (sa.UniqueConstraint('port_id',
'opt_name',
name='uidx_portid_optname'),
ip_version = sa.Column(sa.Integer, server_default='4', nullable=False)
__table_args__ = (sa.UniqueConstraint(
'port_id',
'opt_name',
'ip_version',
name='uniq_extradhcpopts0portid0optname0ipversion'),
model_base.BASEV2.__table_args__,)
# Add a relationship to the Port model in order to instruct SQLAlchemy to
@ -62,10 +65,12 @@ class ExtraDhcpOptMixin(object):
with context.session.begin(subtransactions=True):
for dopt in extra_dhcp_opts:
if dopt['opt_value']:
ip_version = dopt.get('ip_version', 4)
db = ExtraDhcpOpt(
port_id=port['id'],
opt_name=dopt['opt_name'],
opt_value=dopt['opt_value'])
opt_value=dopt['opt_value'],
ip_version=ip_version)
context.session.add(db)
return self._extend_port_extra_dhcp_opts_dict(context, port)
@ -76,7 +81,8 @@ class ExtraDhcpOptMixin(object):
def _get_port_extra_dhcp_opts_binding(self, context, port_id):
query = self._model_query(context, ExtraDhcpOpt)
binding = query.filter(ExtraDhcpOpt.port_id == port_id)
return [{'opt_name': r.opt_name, 'opt_value': r.opt_value}
return [{'opt_name': r.opt_name, 'opt_value': r.opt_value,
'ip_version': r.ip_version}
for r in binding]
def _update_extra_dhcp_opts_on_port(self, context, id, port,
@ -93,20 +99,25 @@ class ExtraDhcpOptMixin(object):
with context.session.begin(subtransactions=True):
for upd_rec in dopts:
for opt in opt_db:
if opt['opt_name'] == upd_rec['opt_name']:
if (opt['opt_name'] == upd_rec['opt_name']
and opt['ip_version'] == upd_rec.get(
'ip_version', 4)):
# to handle deleting of a opt from the port.
if upd_rec['opt_value'] is None:
context.session.delete(opt)
elif opt['opt_value'] != upd_rec['opt_value']:
opt.update(
{'opt_value': upd_rec['opt_value']})
else:
if opt['opt_value'] != upd_rec['opt_value']:
opt.update(
{'opt_value': upd_rec['opt_value']})
break
else:
if upd_rec['opt_value'] is not None:
ip_version = upd_rec.get('ip_version', 4)
db = ExtraDhcpOpt(
port_id=id,
opt_name=upd_rec['opt_name'],
opt_value=upd_rec['opt_value'])
opt_value=upd_rec['opt_value'],
ip_version=ip_version)
context.session.add(db)
if updated_port:
@ -117,7 +128,8 @@ class ExtraDhcpOptMixin(object):
def _extend_port_dict_extra_dhcp_opt(self, res, port):
res[edo_ext.EXTRADHCPOPTS] = [{'opt_name': dho.opt_name,
'opt_value': dho.opt_value}
'opt_value': dho.opt_value,
'ip_version': dho.ip_version}
for dho in port.dhcp_opts]
return res

View File

@ -0,0 +1,70 @@
# Copyright 2015 OpenStack Foundation
#
# 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.
#
"""extra_dhcp_options IPv6 support
Revision ID: 16cdf118d31d
Revises: 14be42f3d0a5
Create Date: 2014-10-23 17:04:19.796731
"""
# revision identifiers, used by Alembic.
revision = '16cdf118d31d'
down_revision = '14be42f3d0a5'
from alembic import op
import sqlalchemy as sa
from neutron.db import migration
CONSTRAINT_NAME_OLD = 'uidx_portid_optname'
CONSTRAINT_NAME_NEW = 'uniq_extradhcpopts0portid0optname0ipversion'
TABLE_NAME = 'extradhcpopts'
def upgrade():
with migration.remove_fks_from_table(TABLE_NAME):
op.drop_constraint(
name=CONSTRAINT_NAME_OLD,
table_name=TABLE_NAME,
type_='unique'
)
op.add_column('extradhcpopts', sa.Column('ip_version', sa.Integer(),
server_default='4', nullable=False))
op.execute("UPDATE extradhcpopts SET ip_version = 4")
op.create_unique_constraint(
name=CONSTRAINT_NAME_NEW,
source='extradhcpopts',
local_cols=['port_id', 'opt_name', 'ip_version']
)
def downgrade():
with migration.remove_fks_from_table(TABLE_NAME):
op.drop_constraint(
name=CONSTRAINT_NAME_NEW,
table_name='extradhcpopts',
type_='unique'
)
op.drop_column('extradhcpopts', 'ip_version')
op.create_unique_constraint(
name=CONSTRAINT_NAME_OLD,
source='extradhcpopts',
local_cols=['port_id', 'opt_name']
)

View File

@ -1 +1 @@
14be42f3d0a5
16cdf118d31d

View File

@ -55,7 +55,10 @@ EXTENDED_ATTRIBUTES_2_0 = {
'opt_name': {'type:not_empty_string': None,
'required': True},
'opt_value': {'type:not_empty_string_or_none': None,
'required': True}}}}}}
'required': True},
'ip_version': {'convert_to': attr.convert_to_int,
'type:values': [4, 6],
'required': False}}}}}}
class Extra_dhcp_opt(extensions.ExtensionDescriptor):

View File

@ -64,10 +64,12 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
for opt in returned:
name = opt['opt_name']
for exp in expected:
if name == exp['opt_name']:
if (name == exp['opt_name']
and opt['ip_version'] == exp.get(
'ip_version', 4)):
val = exp['opt_value']
break
self.assertEqual(opt['opt_value'], val)
self.assertEqual(val, opt['opt_value'])
def test_create_port_with_extradhcpopts(self):
opt_list = [{'opt_name': 'bootfile-name',
@ -103,6 +105,55 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
self._check_opts(expected,
port['port'][edo_ext.EXTRADHCPOPTS])
def test_create_port_with_extradhcpopts_ipv4_opt_version(self):
opt_list = [{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0',
'ip_version': 4},
{'opt_name': 'server-ip-address',
'opt_value': '123.123.123.456',
'ip_version': 4},
{'opt_name': 'tftp-server',
'opt_value': '123.123.123.123',
'ip_version': 4}]
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
self._check_opts(opt_list,
port['port'][edo_ext.EXTRADHCPOPTS])
def test_create_port_with_extradhcpopts_ipv6_opt_version(self):
opt_list = [{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0',
'ip_version': 6},
{'opt_name': 'tftp-server',
'opt_value': '2001:192:168::1',
'ip_version': 6}]
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
self._check_opts(opt_list,
port['port'][edo_ext.EXTRADHCPOPTS])
def _test_update_port_with_extradhcpopts(self, opt_list, upd_opts,
expected_opts):
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: upd_opts}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
res = req.get_response(self.api)
self.assertEqual(res.status_int, webob.exc.HTTPOk.code)
port = self.deserialize('json', res)
self._check_opts(expected_opts,
port['port'][edo_ext.EXTRADHCPOPTS])
def test_update_port_with_extradhcpopts_with_same(self):
opt_list = [{'opt_name': 'bootfile-name', 'opt_value': 'pxelinux.0'},
{'opt_name': 'tftp-server',
@ -115,18 +166,8 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
if i['opt_name'] == upd_opts[0]['opt_name']:
i['opt_value'] = upd_opts[0]['opt_value']
break
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: upd_opts}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
port = self.deserialize('json', req.get_response(self.api))
self._check_opts(expected_opts,
port['port'][edo_ext.EXTRADHCPOPTS])
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_with_additional_extradhcpopt(self):
opt_list = [{'opt_name': 'tftp-server',
@ -136,17 +177,8 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
upd_opts = [{'opt_name': 'bootfile-name', 'opt_value': 'changeme.0'}]
expected_opts = copy.deepcopy(opt_list)
expected_opts.append(upd_opts[0])
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: upd_opts}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
port = self.deserialize('json', req.get_response(self.api))
self._check_opts(expected_opts,
port['port'][edo_ext.EXTRADHCPOPTS])
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_with_extradhcpopts(self):
opt_list = [{'opt_name': 'bootfile-name', 'opt_value': 'pxelinux.0'},
@ -160,18 +192,8 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
if i['opt_name'] == upd_opts[0]['opt_name']:
i['opt_value'] = upd_opts[0]['opt_value']
break
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: upd_opts}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
port = self.deserialize('json', req.get_response(self.api))
self._check_opts(expected_opts,
port['port'][edo_ext.EXTRADHCPOPTS])
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_with_extradhcpopt_delete(self):
opt_list = [{'opt_name': 'bootfile-name', 'opt_value': 'pxelinux.0'},
@ -184,45 +206,26 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
expected_opts = [opt for opt in opt_list
if opt['opt_name'] != 'bootfile-name']
params = {edo_ext.EXTRADHCPOPTS: opt_list,
'arg_list': (edo_ext.EXTRADHCPOPTS,)}
with self.port(**params) as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: upd_opts}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
port = self.deserialize('json', req.get_response(self.api))
self._check_opts(expected_opts,
port['port'][edo_ext.EXTRADHCPOPTS])
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_without_extradhcpopt_delete(self):
opt_list = []
upd_opts = [{'opt_name': 'bootfile-name', 'opt_value': None}]
with self.port() as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: upd_opts}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
port = self.deserialize('json', req.get_response(self.api))
edo_attr = port['port'].get(edo_ext.EXTRADHCPOPTS)
self.assertEqual(edo_attr, [])
expected_opts = []
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_adding_extradhcpopts(self):
opt_list = [{'opt_name': 'bootfile-name', 'opt_value': 'pxelinux.0'},
opt_list = []
upd_opts = [{'opt_name': 'bootfile-name', 'opt_value': 'pxelinux.0'},
{'opt_name': 'tftp-server',
'opt_value': '123.123.123.123'},
{'opt_name': 'server-ip-address',
'opt_value': '123.123.123.456'}]
with self.port() as port:
update_port = {'port': {edo_ext.EXTRADHCPOPTS: opt_list}}
req = self.new_update_request('ports', update_port,
port['port']['id'])
port = self.deserialize('json', req.get_response(self.api))
self._check_opts(opt_list,
port['port'][edo_ext.EXTRADHCPOPTS])
expected_opts = copy.deepcopy(upd_opts)
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_with_blank_string_extradhcpopt(self):
opt_list = [{'opt_name': 'bootfile-name', 'opt_value': 'pxelinux.0'},
@ -261,3 +264,36 @@ class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
port['port']['id'])
res = req.get_response(self.api)
self.assertEqual(res.status_int, webob.exc.HTTPBadRequest.code)
def test_update_port_with_extradhcpopts_ipv6_change_value(self):
opt_list = [{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0',
'ip_version': 6},
{'opt_name': 'tftp-server',
'opt_value': '2001:192:168::1',
'ip_version': 6}]
upd_opts = [{'opt_name': 'tftp-server',
'opt_value': '2001:192:168::2',
'ip_version': 6}]
expected_opts = copy.deepcopy(opt_list)
for i in expected_opts:
if i['opt_name'] == upd_opts[0]['opt_name']:
i['opt_value'] = upd_opts[0]['opt_value']
break
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)
def test_update_port_with_extradhcpopts_add_another_ver_opt(self):
opt_list = [{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0',
'ip_version': 6},
{'opt_name': 'tftp-server',
'opt_value': '2001:192:168::1',
'ip_version': 6}]
upd_opts = [{'opt_name': 'tftp-server',
'opt_value': '123.123.123.123',
'ip_version': 4}]
expected_opts = copy.deepcopy(opt_list)
expected_opts.extend(upd_opts)
self._test_update_port_with_extradhcpopts(opt_list, upd_opts,
expected_opts)

View File

@ -39,6 +39,7 @@ class FakeIPAllocation(object):
class DhcpOpt(object):
def __init__(self, **kwargs):
self.__dict__.update(ip_version=4)
self.__dict__.update(kwargs)
def __str__(self):
@ -465,6 +466,34 @@ class FakeV4NetworkPxe3Ports(object):
DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux3.0')]
class FakeV6NetworkPxePort(object):
id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
subnets = [FakeV6SubnetDHCPStateful()]
ports = [FakeV6Port()]
namespace = 'qdhcp-ns'
def __init__(self):
self.ports[0].extra_dhcp_opts = [
DhcpOpt(opt_name='tftp-server', opt_value='2001:192:168::1',
ip_version=6),
DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0',
ip_version=6)]
class FakeV6NetworkPxePortWrongOptVersion(object):
id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
subnets = [FakeV6SubnetDHCPStateful()]
ports = [FakeV6Port()]
namespace = 'qdhcp-ns'
def __init__(self):
self.ports[0].extra_dhcp_opts = [
DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.7',
ip_version=4),
DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0',
ip_version=6)]
class FakeDualStackNetworkSingleDHCP(object):
id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'
@ -1036,6 +1065,40 @@ class TestDnsmasq(TestBase):
self.safe.assert_called_once_with('/foo/opts', expected)
@mock.patch('neutron.agent.linux.dhcp.Dnsmasq.get_conf_file_name',
return_value='/foo/opts')
def test_output_opts_file_pxe_ipv6_port_with_ipv6_opt(self,
mock_get_conf_fn):
expected = (
'tag:tag0,option6:dns-server,[2001:0200:feed:7ac0::1]\n'
'tag:tag0,option6:domain-search,openstacklocal\n'
'tag:hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh,'
'option6:tftp-server,2001:192:168::1\n'
'tag:hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh,'
'option6:bootfile-name,pxelinux.0')
expected = expected.lstrip()
dm = self._get_dnsmasq(FakeV6NetworkPxePort())
dm._output_opts_file()
self.safe.assert_called_once_with('/foo/opts', expected)
@mock.patch('neutron.agent.linux.dhcp.Dnsmasq.get_conf_file_name',
return_value='/foo/opts')
def test_output_opts_file_pxe_ipv6_port_with_ipv4_opt(self,
mock_get_conf_fn):
expected = (
'tag:tag0,option6:dns-server,[2001:0200:feed:7ac0::1]\n'
'tag:tag0,option6:domain-search,openstacklocal\n'
'tag:hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh,'
'option6:bootfile-name,pxelinux.0')
expected = expected.lstrip()
dm = self._get_dnsmasq(FakeV6NetworkPxePortWrongOptVersion())
dm._output_opts_file()
self.safe.assert_called_once_with('/foo/opts', expected)
@property
def _test_no_dhcp_domain_alloc_data(self):
exp_host_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/host'