]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
bp: pxeboot-port, provide pxeboot on ports
authordekehn <dekehn@gmail.com>
Sat, 31 Aug 2013 02:15:12 +0000 (20:15 -0600)
committerdekehn <dekehn@gmail.com>
Sat, 31 Aug 2013 16:40:56 +0000 (10:40 -0600)
Teach neutron how to manage PXE boot.

Allow pxe boot parameters to be specified when creating a network port.

Implements bp:pxeboot-ports

Change-Id: I45fe7a16bc6c5975a765dd6a065558b9ba702e5b

neutron/agent/linux/dhcp.py
neutron/db/extradhcpopt_db.py [new file with mode: 0644]
neutron/db/migration/alembic_migrations/versions/53bbd27ec841_extra_dhcp_opts_supp.py [new file with mode: 0644]
neutron/extensions/extra_dhcp_opt.py [new file with mode: 0644]
neutron/plugins/openvswitch/ovs_neutron_plugin.py
neutron/tests/unit/test_extension_extradhcpopts.py [new file with mode: 0644]
neutron/tests/unit/test_linux_dhcp.py

index 060ed3b1892450854b83d5582351e47787b333e3..5257ca92f907aab735c893fd77ea036f3a91a62f 100644 (file)
@@ -397,8 +397,17 @@ class Dnsmasq(DhcpLocalProcess):
             for alloc in port.fixed_ips:
                 name = 'host-%s.%s' % (r.sub('-', alloc.ip_address),
                                        self.conf.dhcp_domain)
-                buf.write('%s,%s,%s\n' %
-                          (port.mac_address, name, alloc.ip_address))
+                set_tag = ''
+                if port.extra_dhcp_opts:
+                    if self.version >= self.MINIMUM_VERSION:
+                        set_tag = 'set:'
+
+                    buf.write('%s,%s,%s,%s%s\n' %
+                              (port.mac_address, name, alloc.ip_address,
+                               set_tag, port.id))
+                else:
+                    buf.write('%s,%s,%s\n' %
+                              (port.mac_address, name, alloc.ip_address))
 
         name = self.get_conf_file_name('host')
         utils.replace_file(name, buf.getvalue())
@@ -453,6 +462,12 @@ class Dnsmasq(DhcpLocalProcess):
                 else:
                     options.append(self._format_option(i, 'router'))
 
+        for port in self.network.ports:
+            if port.extra_dhcp_opts:
+                options.extend(
+                    self._format_option(port.id, opt.opt_name, opt.opt_value)
+                    for opt in port.extra_dhcp_opts)
+
         name = self.get_conf_file_name('opts')
         utils.replace_file(name, '\n'.join(options))
         return name
@@ -479,17 +494,22 @@ class Dnsmasq(DhcpLocalProcess):
 
         return retval
 
-    def _format_option(self, index, option, *args):
+    def _format_option(self, 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
-        return ','.join((set_tag + self._TAG_PREFIX % index,
-                         option) + args)
+
+        return ','.join((set_tag + tag, '%s' % option) + args)
 
     @classmethod
     def lease_update(cls):
diff --git a/neutron/db/extradhcpopt_db.py b/neutron/db/extradhcpopt_db.py
new file mode 100644 (file)
index 0000000..abbccba
--- /dev/null
@@ -0,0 +1,131 @@
+# Copyright (c) 2013 OpenStack Foundation.
+# All Rights Reserved.
+#
+#    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.
+#
+# @author: Don Kehn, dekehn@gmail.com
+#
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from neutron.api.v2 import attributes
+from neutron.db import db_base_plugin_v2
+from neutron.db import model_base
+from neutron.db import models_v2
+from neutron.extensions import extra_dhcp_opt as edo_ext
+from neutron.openstack.common import log as logging
+
+LOG = logging.getLogger(__name__)
+
+
+class ExtraDhcpOpt(model_base.BASEV2, models_v2.HasId):
+    """Represent a generic concept of extra options associated to a port.
+
+    Each port may have none to many dhcp opts associated to it that can
+    define specifically different or extra options to DHCP clients.
+    These will be written to the <network_id>/opts files, and each option's
+    tag will be referenced in the <network_id>/host file.
+    """
+    port_id = sa.Column(sa.String(36),
+                        sa.ForeignKey('ports.id', ondelete="CASCADE"),
+                        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'),)
+
+    # Add a relationship to the Port model in order to instruct SQLAlchemy to
+    # eagerly load extra_dhcp_opts bindings
+    ports = orm.relationship(
+        models_v2.Port,
+        backref=orm.backref("dhcp_opts", lazy='joined', cascade='delete'))
+
+
+class ExtraDhcpOptMixin(object):
+    """Mixin class to add extra options to the DHCP opts file
+    and associate them to a port.
+    """
+    def _process_port_create_extra_dhcp_opts(self, context, port,
+                                             extra_dhcp_opts):
+        if not extra_dhcp_opts:
+            return port
+        with context.session.begin(subtransactions=True):
+            for dopt in extra_dhcp_opts:
+                db = ExtraDhcpOpt(
+                    port_id=port['id'],
+                    opt_name=dopt['opt_name'],
+                    opt_value=dopt['opt_value'])
+                context.session.add(db)
+        return self._extend_port_extra_dhcp_opts_dict(context, port)
+
+    def _extend_port_extra_dhcp_opts_dict(self, context, port):
+        port[edo_ext.EXTRADHCPOPTS] = self._get_port_extra_dhcp_opts_binding(
+            context, port['id'])
+
+    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}
+                for r in binding]
+
+    def _update_extra_dhcp_opts_on_port(self, context, id, port,
+                                        updated_port=None):
+        # It is not necessary to update in a transaction, because
+        # its called from within one from ovs_neutron_plugin.
+        dopts = port['port'].get(edo_ext.EXTRADHCPOPTS)
+
+        if dopts:
+            opt_db = self._model_query(
+                context, ExtraDhcpOpt).filter_by(port_id=id).all()
+            # if there are currently no dhcp_options associated to
+            # this port, Then just insert the new ones and be done.
+            if not opt_db:
+                with context.session.begin(subtransactions=True):
+                    for dopt in dopts:
+                        db = ExtraDhcpOpt(
+                            port_id=id,
+                            opt_name=dopt['opt_name'],
+                            opt_value=dopt['opt_value'])
+                        context.session.add(db)
+            else:
+                for upd_rec in dopts:
+                    with context.session.begin(subtransactions=True):
+                        for opt in opt_db:
+                            if opt['opt_name'] == upd_rec['opt_name']:
+                                if opt['opt_value'] != upd_rec['opt_value']:
+                                    opt.update(
+                                        {'opt_value': upd_rec['opt_value']})
+                                break
+                        # this handles the adding an option that didn't exist.
+                        else:
+                            db = ExtraDhcpOpt(
+                                port_id=id,
+                                opt_name=upd_rec['opt_name'],
+                                opt_value=upd_rec['opt_value'])
+                            context.session.add(db)
+
+            if updated_port:
+                edolist = self._get_port_extra_dhcp_opts_binding(context, id)
+                updated_port[edo_ext.EXTRADHCPOPTS] = edolist
+
+        return bool(dopts)
+
+    def _extend_port_dict_extra_dhcp_opt(self, res, port):
+        res[edo_ext.EXTRADHCPOPTS] = [{'opt_name': dho.opt_name,
+                                       'opt_value': dho.opt_value}
+                                      for dho in port.dhcp_opts]
+        return res
+
+    db_base_plugin_v2.NeutronDbPluginV2.register_dict_extend_funcs(
+        attributes.PORTS, [_extend_port_dict_extra_dhcp_opt])
diff --git a/neutron/db/migration/alembic_migrations/versions/53bbd27ec841_extra_dhcp_opts_supp.py b/neutron/db/migration/alembic_migrations/versions/53bbd27ec841_extra_dhcp_opts_supp.py
new file mode 100644 (file)
index 0000000..e44d7da
--- /dev/null
@@ -0,0 +1,64 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2013 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 opts support
+
+Revision ID: 53bbd27ec841
+Revises: 40dffbf4b549
+Create Date: 2013-05-09 15:36:50.485036
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '53bbd27ec841'
+down_revision = '40dffbf4b549'
+
+# Change to ['*'] if this migration applies to all plugins
+
+migration_for_plugins = [
+    'neutron.plugins.openvswitch.ovs_neutron_plugin.OVSNeutronPluginV2'
+]
+
+from alembic import op
+import sqlalchemy as sa
+
+
+from neutron.db import migration
+
+
+def upgrade(active_plugins=None, options=None):
+    if not migration.should_run(active_plugins, migration_for_plugins):
+        return
+
+    op.create_table(
+        'extradhcpopts',
+        sa.Column('id', sa.String(length=36), nullable=False),
+        sa.Column('port_id', sa.String(length=36), nullable=False),
+        sa.Column('opt_name', sa.String(length=64), nullable=False),
+        sa.Column('opt_value', sa.String(length=255), nullable=False),
+        sa.ForeignKeyConstraint(['port_id'], ['ports.id'], ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('id'),
+        sa.UniqueConstraint('port_id', 'opt_name', name='uidx_portid_optname'))
+
+
+def downgrade(active_plugins=None, options=None):
+    if not migration.should_run(active_plugins, migration_for_plugins):
+        return
+
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('extradhcpopts')
+    ### end Alembic commands ###
diff --git a/neutron/extensions/extra_dhcp_opt.py b/neutron/extensions/extra_dhcp_opt.py
new file mode 100644 (file)
index 0000000..39995cc
--- /dev/null
@@ -0,0 +1,89 @@
+# Copyright (c) 2013 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.
+#
+# @Author Don Kehn, dekehn@gmail.com
+
+from neutron.api import extensions
+from neutron.api.v2 import attributes as attr
+from neutron.common import exceptions
+
+
+# ExtraDHcpOpts Exceptions
+class ExtraDhcpOptNotFound(exceptions.NotFound):
+    message = _("ExtraDhcpOpt %(id)s could not be found")
+
+
+class ExtraDhcpOptBadData(exceptions.InvalidInput):
+    message = _("Invalid data format for extra-dhcp-opt, "
+                "provide a list of dicts: %(data)s")
+
+
+def _validate_list_of_dict_or_none(data, key_specs=None):
+    if data is not None:
+        if not isinstance(data, list):
+            raise ExtraDhcpOptBadData(data=data)
+        for d in data:
+            msg = attr._validate_dict(d, key_specs)
+            if msg:
+                raise ExtraDhcpOptBadData(data=msg)
+
+attr.validators['type:list_of_dict_or_none'] = _validate_list_of_dict_or_none
+
+# Attribute Map
+EXTRADHCPOPTS = 'extra_dhcp_opts'
+
+EXTENDED_ATTRIBUTES_2_0 = {
+    'ports': {
+        EXTRADHCPOPTS:
+        {'allow_post': True,
+         'allow_put': True,
+         'is_visible': True,
+         'default': None,
+         'validate': {
+             'type:list_of_dict_or_none': {
+                 'id': {'type:uuid': None, 'required': False},
+                 'opt_name': {'type:string': None, 'required': True},
+                 'opt_value': {'type:string': None, 'required': True}}}}}}
+
+
+class Extra_dhcp_opt(extensions.ExtensionDescriptor):
+    @classmethod
+    def get_name(cls):
+        return "Neutron Extra DHCP opts"
+
+    @classmethod
+    def get_alias(cls):
+        return "extra_dhcp_opt"
+
+    @classmethod
+    def get_description(cls):
+        return ("Extra options configuration for DHCP. "
+                "For example PXE boot options to DHCP clients can "
+                "be specified (e.g. tftp-server, server-ip-address, "
+                "bootfile-name)")
+
+    @classmethod
+    def get_namespace(cls):
+        return "http://docs.openstack.org/ext/neutron/extra_dhcp_opt/api/v1.0"
+
+    @classmethod
+    def get_updated(cls):
+        return "2013-03-17T12:00:00-00:00"
+
+    def get_extended_resources(self, version):
+        if version == "2.0":
+            return EXTENDED_ATTRIBUTES_2_0
+        else:
+            return {}
index baeef5ef548fa56bfd148b075ae743b0e8064524..31312bf4ea701255a31b87dcc879ec64ee1dd255 100644 (file)
@@ -38,12 +38,14 @@ from neutron.db import agents_db
 from neutron.db import agentschedulers_db
 from neutron.db import db_base_plugin_v2
 from neutron.db import dhcp_rpc_base
+from neutron.db import extradhcpopt_db
 from neutron.db import extraroute_db
 from neutron.db import l3_gwmode_db
 from neutron.db import l3_rpc_base
 from neutron.db import portbindings_db
 from neutron.db import quota_db  # noqa
 from neutron.db import securitygroups_rpc_base as sg_db_rpc
+from neutron.extensions import extra_dhcp_opt as edo_ext
 from neutron.extensions import portbindings
 from neutron.extensions import providernet as provider
 from neutron.openstack.common import importutils
@@ -222,7 +224,8 @@ class OVSNeutronPluginV2(db_base_plugin_v2.NeutronDbPluginV2,
                          sg_db_rpc.SecurityGroupServerRpcMixin,
                          agentschedulers_db.L3AgentSchedulerDbMixin,
                          agentschedulers_db.DhcpAgentSchedulerDbMixin,
-                         portbindings_db.PortBindingMixin):
+                         portbindings_db.PortBindingMixin,
+                         extradhcpopt_db.ExtraDhcpOptMixin):
 
     """Implement the Neutron abstractions using Open vSwitch.
 
@@ -252,7 +255,8 @@ class OVSNeutronPluginV2(db_base_plugin_v2.NeutronDbPluginV2,
                                     "binding", "quotas", "security-group",
                                     "agent", "extraroute",
                                     "l3_agent_scheduler",
-                                    "dhcp_agent_scheduler"]
+                                    "dhcp_agent_scheduler",
+                                    "extra_dhcp_opt"]
 
     @property
     def supported_extension_aliases(self):
@@ -536,10 +540,13 @@ class OVSNeutronPluginV2(db_base_plugin_v2.NeutronDbPluginV2,
         with session.begin(subtransactions=True):
             self._ensure_default_security_group_on_port(context, port)
             sgids = self._get_security_groups_on_port(context, port)
+            dhcp_opts = port['port'].get(edo_ext.EXTRADHCPOPTS, [])
             port = super(OVSNeutronPluginV2, self).create_port(context, port)
             self._process_portbindings_create_and_update(context,
                                                          port_data, port)
             self._process_port_create_security_group(context, port, sgids)
+            self._process_port_create_extra_dhcp_opts(context, port,
+                                                      dhcp_opts)
         self.notify_security_groups_member_updated(context, port)
         return port
 
@@ -556,6 +563,9 @@ class OVSNeutronPluginV2(db_base_plugin_v2.NeutronDbPluginV2,
             self._process_portbindings_create_and_update(context,
                                                          port['port'],
                                                          updated_port)
+            need_port_update_notify |= self._update_extra_dhcp_opts_on_port(
+                context, id, port, updated_port)
+
         need_port_update_notify |= self.is_security_group_member_updated(
             context, original_port, updated_port)
         if original_port['admin_state_up'] != updated_port['admin_state_up']:
diff --git a/neutron/tests/unit/test_extension_extradhcpopts.py b/neutron/tests/unit/test_extension_extradhcpopts.py
new file mode 100644 (file)
index 0000000..fbe5836
--- /dev/null
@@ -0,0 +1,172 @@
+# Copyright (c) 2013 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.
+#
+# @author: D.E. Kehn, dekehn@gmail.com
+#
+
+import copy
+
+from neutron.db import db_base_plugin_v2
+from neutron.db import extradhcpopt_db as edo_db
+from neutron.extensions import extra_dhcp_opt as edo_ext
+from neutron.openstack.common import log as logging
+from neutron.tests.unit import test_db_plugin
+
+LOG = logging.getLogger(__name__)
+
+DB_PLUGIN_KLASS = (
+    'neutron.tests.unit.test_extension_extradhcpopts.ExtraDhcpOptTestPlugin')
+
+
+class ExtraDhcpOptTestPlugin(db_base_plugin_v2.NeutronDbPluginV2,
+                             edo_db.ExtraDhcpOptMixin):
+    """Test plugin that implements necessary calls on create/delete port for
+    associating ports with extra dhcp options.
+    """
+
+    supported_extension_aliases = ["extra_dhcp_opt"]
+
+    def create_port(self, context, port):
+        with context.session.begin(subtransactions=True):
+            edos = port['port'].get(edo_ext.EXTRADHCPOPTS, [])
+            new_port = super(ExtraDhcpOptTestPlugin, self).create_port(
+                context, port)
+            self._process_port_create_extra_dhcp_opts(context, new_port, edos)
+        return new_port
+
+    def update_port(self, context, id, port):
+        with context.session.begin(subtransactions=True):
+            rtn_port = super(ExtraDhcpOptTestPlugin, self).update_port(
+                context, id, port)
+            self._update_extra_dhcp_opts_on_port(context, id, port, rtn_port)
+        return rtn_port
+
+
+class ExtraDhcpOptDBTestCase(test_db_plugin.NeutronDbPluginV2TestCase):
+    def setUp(self, plugin=None):
+        super(ExtraDhcpOptDBTestCase, self).setUp(plugin=DB_PLUGIN_KLASS)
+
+
+class TestExtraDhcpOpt(ExtraDhcpOptDBTestCase):
+    def _check_opts(self, expected, returned):
+        self.assertEqual(len(expected), len(returned))
+        for opt in returned:
+            name = opt['opt_name']
+            for exp in expected:
+                if name == exp['opt_name']:
+                    val = exp['opt_value']
+                    break
+            self.assertEqual(opt['opt_value'], val)
+
+    def test_create_port_with_extradhcpopts(self):
+        opt_dict = [{'opt_name': 'bootfile-name',
+                     'opt_value': 'pxelinux.0'},
+                    {'opt_name': 'server-ip-address',
+                     'opt_value': '123.123.123.456'},
+                    {'opt_name': 'tftp-server',
+                     'opt_value': '123.123.123.123'}]
+
+        params = {edo_ext.EXTRADHCPOPTS: opt_dict,
+                  'arg_list': (edo_ext.EXTRADHCPOPTS,)}
+
+        with self.port(**params) as port:
+            self._check_opts(opt_dict,
+                             port['port'][edo_ext.EXTRADHCPOPTS])
+
+    def test_update_port_with_extradhcpopts_with_same(self):
+        opt_dict = [{'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'}]
+        upd_opts = [{'opt_name': 'bootfile-name', 'opt_value': 'changeme.0'}]
+        new_opts = opt_dict[:]
+        for i in new_opts:
+            if i['opt_name'] == upd_opts[0]['opt_name']:
+                i['opt_value'] = upd_opts[0]['opt_value']
+                break
+
+        params = {edo_ext.EXTRADHCPOPTS: opt_dict,
+                  '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(new_opts,
+                             port['port'][edo_ext.EXTRADHCPOPTS])
+
+    def test_update_port_with_extradhcpopts(self):
+        opt_dict = [{'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'}]
+        upd_opts = [{'opt_name': 'bootfile-name', 'opt_value': 'changeme.0'}]
+        new_opts = copy.deepcopy(opt_dict)
+        for i in new_opts:
+            if i['opt_name'] == upd_opts[0]['opt_name']:
+                i['opt_value'] = upd_opts[0]['opt_value']
+                break
+
+        params = {edo_ext.EXTRADHCPOPTS: opt_dict,
+                  '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(new_opts,
+                             port['port'][edo_ext.EXTRADHCPOPTS])
+
+    def test_update_port_with_extradhcpopt1(self):
+        opt_dict = [{'opt_name': 'tftp-server',
+                     'opt_value': '123.123.123.123'},
+                    {'opt_name': 'server-ip-address',
+                     'opt_value': '123.123.123.456'}]
+        upd_opts = [{'opt_name': 'bootfile-name', 'opt_value': 'changeme.0'}]
+        new_opts = copy.deepcopy(opt_dict)
+        new_opts.append(upd_opts[0])
+
+        params = {edo_ext.EXTRADHCPOPTS: opt_dict,
+                  '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(new_opts,
+                             port['port'][edo_ext.EXTRADHCPOPTS])
+
+    def test_update_port_adding_extradhcpopts(self):
+        opt_dict = [{'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_dict}}
+
+            req = self.new_update_request('ports', update_port,
+                                          port['port']['id'])
+            port = self.deserialize('json', req.get_response(self.api))
+            self._check_opts(opt_dict,
+                             port['port'][edo_ext.EXTRADHCPOPTS])
index 6be5c954c14e1f8c1ab9a57cd7f1ff6ea168290e..34f16267fa5a5735dbc8ed3ed5f8b88c8238dc5d 100644 (file)
@@ -23,20 +23,34 @@ from oslo.config import cfg
 from neutron.agent.common import config
 from neutron.agent.linux import dhcp
 from neutron.common import config as base_config
+from neutron.openstack.common import log as logging
 from neutron.tests import base
 
+LOG = logging.getLogger(__name__)
+
 
 class FakeIPAllocation:
     def __init__(self, address):
         self.ip_address = address
 
 
+class DhcpOpt(object):
+    def __init__(self, **kwargs):
+        self.__dict__.update(kwargs)
+
+    def __str__(self):
+        return str(self.__dict__)
+
+
 class FakePort1:
     id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'
     admin_state_up = True
     fixed_ips = [FakeIPAllocation('192.168.0.2')]
     mac_address = '00:00:80:aa:bb:cc'
 
+    def __init__(self):
+        self.extra_dhcp_opts = []
+
 
 class FakePort2:
     id = 'ffffffff-ffff-ffff-ffff-ffffffffffff'
@@ -44,6 +58,9 @@ class FakePort2:
     fixed_ips = [FakeIPAllocation('fdca:3ba5:a17a:4ba3::2')]
     mac_address = '00:00:f3:aa:bb:cc'
 
+    def __init__(self):
+        self.extra_dhcp_opts = []
+
 
 class FakePort3:
     id = '44444444-4444-4444-4444-444444444444'
@@ -52,6 +69,9 @@ class FakePort3:
                  FakeIPAllocation('fdca:3ba5:a17a:4ba3::3')]
     mac_address = '00:00:0f:aa:bb:cc'
 
+    def __init__(self):
+        self.extra_dhcp_opts = []
+
 
 class FakeV4HostRoute:
     destination = '20.0.0.1/24'
@@ -157,6 +177,103 @@ class FakeV4NoGatewayNetwork:
     ports = [FakePort1()]
 
 
+class FakeDualV4Pxe3Ports:
+    id = 'cccccccc-cccc-cccc-cccc-cccccccccccc'
+    subnets = [FakeV4Subnet(), FakeV4SubnetNoDHCP()]
+    ports = [FakePort1(), FakePort2(), FakePort3()]
+    namespace = 'qdhcp-ns'
+
+    def __init__(self, port_detail="portsSame"):
+        if port_detail == "portsSame":
+            self.ports[0].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[1].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.1.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.1.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux2.0')]
+            self.ports[2].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.1.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.1.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux3.0')]
+        else:
+            self.ports[0].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[1].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.5'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.5'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux2.0')]
+            self.ports[2].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.7'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.7'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux3.0')]
+
+
+class FakeV4NetworkPxe2Ports:
+    id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
+    subnets = [FakeV4Subnet()]
+    ports = [FakePort1(), FakePort2()]
+    namespace = 'qdhcp-ns'
+
+    def __init__(self, port_detail="portsSame"):
+        if port_detail == "portsSame":
+            self.ports[0].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[1].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+        else:
+            self.ports[0].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[1].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.5'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.5'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+
+
+class FakeV4NetworkPxe3Ports:
+    id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
+    subnets = [FakeV4Subnet()]
+    ports = [FakePort1(), FakePort2(), FakePort3()]
+    namespace = 'qdhcp-ns'
+
+    def __init__(self, port_detail="portsSame"):
+        if port_detail == "portsSame":
+            self.ports[0].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[1].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.1.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.1.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[2].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.1.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.1.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+        else:
+            self.ports[0].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.3'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.2'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux.0')]
+            self.ports[1].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.5'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.5'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux2.0')]
+            self.ports[2].extra_dhcp_opts = [
+                DhcpOpt(opt_name='tftp-server', opt_value='192.168.0.7'),
+                DhcpOpt(opt_name='server-ip-address', opt_value='192.168.0.7'),
+                DhcpOpt(opt_name='bootfile-name', opt_value='pxelinux3.0')]
+
+
 class LocalChild(dhcp.DhcpLocalProcess):
     PORTS = {4: [4], 6: [6]}
 
@@ -588,6 +705,101 @@ tag:tag0,option:router""".lstrip()
         self.execute.assert_called_once_with(exp_args, root_helper='sudo',
                                              check_exit_code=True)
 
+    def test_output_opts_file_pxe_2port_1net(self):
+        expected = """
+tag:tag0,option:dns-server,8.8.8.8
+tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1
+tag:tag0,249,20.0.0.1/24,20.0.0.1
+tag:tag0,option:router,192.168.0.1
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:tftp-server,192.168.0.3
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:server-ip-address,192.168.0.2
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:bootfile-name,pxelinux.0
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:tftp-server,192.168.0.3
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:server-ip-address,192.168.0.2
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:bootfile-name,pxelinux.0"""
+        expected = expected.lstrip()
+
+        with mock.patch.object(dhcp.Dnsmasq, 'get_conf_file_name') as conf_fn:
+            conf_fn.return_value = '/foo/opts'
+            fp = FakeV4NetworkPxe2Ports()
+            dm = dhcp.Dnsmasq(self.conf, fp, version=float(2.59))
+            dm._output_opts_file()
+
+        self.safe.assert_called_once_with('/foo/opts', expected)
+
+    def test_output_opts_file_pxe_2port_1net_diff_details(self):
+        expected = """
+tag:tag0,option:dns-server,8.8.8.8
+tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1
+tag:tag0,249,20.0.0.1/24,20.0.0.1
+tag:tag0,option:router,192.168.0.1
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:tftp-server,192.168.0.3
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:server-ip-address,192.168.0.2
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:bootfile-name,pxelinux.0
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:tftp-server,192.168.0.5
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:server-ip-address,192.168.0.5
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:bootfile-name,pxelinux.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, FakeV4NetworkPxe2Ports("portsDiff"),
+                              version=float(2.59))
+            dm._output_opts_file()
+
+        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
+tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1
+tag:tag0,249,20.0.0.1/24,20.0.0.1
+tag:tag0,option:router,192.168.0.1
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:tftp-server,192.168.0.3
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:server-ip-address,192.168.0.2
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:bootfile-name,pxelinux.0
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:tftp-server,192.168.0.5
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:server-ip-address,192.168.0.5
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:bootfile-name,pxelinux2.0
+tag:44444444-4444-4444-4444-444444444444,option:tftp-server,192.168.0.7
+tag:44444444-4444-4444-4444-444444444444,option:server-ip-address,192.168.0.7
+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=float(2.59))
+            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
+tag:tag0,option:classless-static-route,20.0.0.1/24,20.0.0.1
+tag:tag0,249,20.0.0.1/24,20.0.0.1
+tag:tag0,option:router,192.168.0.1
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:tftp-server,192.168.0.3
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:server-ip-address,192.168.0.2
+tag:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee,option:bootfile-name,pxelinux.0
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:tftp-server,192.168.1.3
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:server-ip-address,192.168.1.2
+tag:ffffffff-ffff-ffff-ffff-ffffffffffff,option:bootfile-name,pxelinux2.0
+tag:44444444-4444-4444-4444-444444444444,option:tftp-server,192.168.1.3
+tag:44444444-4444-4444-4444-444444444444,option:server-ip-address,192.168.1.2
+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, FakeDualV4Pxe3Ports(),
+                              version=float(2.59))
+            dm._output_opts_file()
+
+        self.safe.assert_called_once_with('/foo/opts', expected)
+
     def test_reload_allocations(self):
         exp_host_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/host'
         exp_host_data = ('00:00:80:aa:bb:cc,host-192-168-0-2.openstacklocal,'