Browse Source

Add support for global DHCP options with OVN DHCP.

An operator may wish to set certain DHCP options
globally within an environment. This patch adds
configuration options to allow an operator to
specify DHCP option default values that may be
overriden by more specific configuration at the
subnet or port level.

Change-Id: I626c2dcd4ba66466b342da27a2ab50c3cac8b040
Closes-bug: 1785847
Andrew Austin 8 months ago
parent
commit
545d098bfa

+ 34
- 0
networking_ovn/common/config.py View File

@@ -154,6 +154,32 @@ ovn_opts = [
154 154
                        "field is empty. If both subnet's dns_nameservers and "
155 155
                        "this option is empty, then the DNS resolvers on the "
156 156
                        "host running the neutron server will be used.")),
157
+    cfg.DictOpt('ovn_dhcp4_global_options',
158
+                default={},
159
+                help=_("Dictionary of global DHCPv4 options which will be "
160
+                       "automatically set on each subnet upon creation and "
161
+                       "on all existing subnets when Neutron starts.\n"
162
+                       "An empty value for a DHCP option will cause that "
163
+                       "option to be unset globally.\n"
164
+                       "EXAMPLES:\n"
165
+                       "- ntp_server:1.2.3.4,wpad:1.2.3.5 - Set ntp_server "
166
+                       "and wpad\n"
167
+                       "- ntp_server:,wpad:1.2.3.5 - Unset ntp_server and "
168
+                       "set wpad\n"
169
+                       "See the ovn-nb(5) man page for available options.")),
170
+    cfg.DictOpt('ovn_dhcp6_global_options',
171
+                default={},
172
+                help=_("Dictionary of global DHCPv6 options which will be "
173
+                       "automatically set on each subnet upon creation and "
174
+                       "on all existing subnets when Neutron starts.\n"
175
+                       "An empty value for a DHCP option will cause that "
176
+                       "option to be unset globally.\n"
177
+                       "EXAMPLES:\n"
178
+                       "- ntp_server:1.2.3.4,wpad:1.2.3.5 - Set ntp_server "
179
+                       "and wpad\n"
180
+                       "- ntp_server:,wpad:1.2.3.5 - Unset ntp_server and "
181
+                       "set wpad\n"
182
+                       "See the ovn-nb(5) man page for available options.")),
157 183
 ]
158 184
 
159 185
 cfg.CONF.register_opts(ovn_opts, group='ovn')
@@ -241,6 +267,14 @@ def get_dns_servers():
241 267
     return cfg.CONF.ovn.dns_servers
242 268
 
243 269
 
270
+def get_global_dhcpv4_opts():
271
+    return cfg.CONF.ovn.ovn_dhcp4_global_options
272
+
273
+
274
+def get_global_dhcpv6_opts():
275
+    return cfg.CONF.ovn.ovn_dhcp6_global_options
276
+
277
+
244 278
 def setup_logging():
245 279
     """Sets up the logging options for a log with supplied name."""
246 280
     product_name = "networking-ovn"

+ 8
- 0
networking_ovn/common/constants.py View File

@@ -91,6 +91,14 @@ SUPPORTED_DHCP_OPTS = {
91 91
     6: ['server-id', 'dns-server', 'domain-search']}
92 92
 DHCPV6_STATELESS_OPT = 'dhcpv6_stateless'
93 93
 
94
+# When setting global DHCP options, these options will be ignored
95
+# as they are required for basic network functions and will be
96
+# set by Neutron.
97
+GLOBAL_DHCP_OPTS_BLACKLIST = {
98
+    4: ['server_id', 'lease_time', 'mtu', 'router', 'server_mac',
99
+        'dns_server', 'classless_static_route'],
100
+    6: ['dhcpv6_stateless', 'dns_server', 'server_id']}
101
+
94 102
 CHASSIS_DATAPATH_NETDEV = 'netdev'
95 103
 CHASSIS_IFACE_DPDKVHOSTUSER = 'dpdkvhostuser'
96 104
 

+ 65
- 0
networking_ovn/common/maintenance.py View File

@@ -18,12 +18,14 @@ import threading
18 18
 
19 19
 from futurist import periodics
20 20
 from neutron.common import config as n_conf
21
+from neutron_lib import constants as n_const
21 22
 from neutron_lib import context as n_context
22 23
 from neutron_lib import exceptions as n_exc
23 24
 from neutron_lib import worker
24 25
 from oslo_log import log
25 26
 from oslo_utils import timeutils
26 27
 
28
+from networking_ovn.common import config as ovn_conf
27 29
 from networking_ovn.common import constants as ovn_const
28 30
 from networking_ovn.db import maintenance as db_maint
29 31
 from networking_ovn.db import revision as db_rev
@@ -301,3 +303,66 @@ class DBInconsistenciesPeriodics(object):
301 303
         router_id = port['device_id']
302 304
         self._ovn_client._l3_plugin.add_router_interface(
303 305
             admin_context, router_id, {'port_id': port['id']}, may_exist=True)
306
+
307
+    def _check_subnet_global_dhcp_opts(self):
308
+        inconsistent_subnets = []
309
+        admin_context = n_context.get_admin_context()
310
+        subnet_filter = {'enable_dhcp': [True]}
311
+        neutron_subnets = self._ovn_client._plugin.get_subnets(
312
+            admin_context, subnet_filter)
313
+        global_v4_opts = ovn_conf.get_global_dhcpv4_opts()
314
+        global_v6_opts = ovn_conf.get_global_dhcpv6_opts()
315
+        LOG.debug('Checking %s subnets for global DHCP option consistency',
316
+                  len(neutron_subnets))
317
+        for subnet in neutron_subnets:
318
+            ovn_dhcp_opts = self._nb_idl.get_subnet_dhcp_options(
319
+                subnet['id'])['subnet']
320
+            inconsistent_opts = []
321
+            if ovn_dhcp_opts:
322
+                if subnet['ip_version'] == n_const.IP_VERSION_4:
323
+                    for opt, value in global_v4_opts.items():
324
+                        if value != ovn_dhcp_opts['options'].get(opt, None):
325
+                            inconsistent_opts.append(opt)
326
+                if subnet['ip_version'] == n_const.IP_VERSION_6:
327
+                    for opt, value in global_v6_opts.items():
328
+                        if value != ovn_dhcp_opts['options'].get(opt, None):
329
+                            inconsistent_opts.append(opt)
330
+            if inconsistent_opts:
331
+                LOG.debug('Subnet %s has inconsistent DHCP opts: %s',
332
+                          subnet['id'], inconsistent_opts)
333
+                inconsistent_subnets.append(subnet)
334
+        return inconsistent_subnets
335
+
336
+    # A static spacing value is used here, but this method will only run
337
+    # once per lock due to the use of periodics.NeverAgain().
338
+    @periodics.periodic(spacing=600,
339
+                        run_immediately=True)
340
+    def check_global_dhcp_opts(self):
341
+        # This periodic task is included in DBInconsistenciesPeriodics since
342
+        # it uses the lock to ensure only one worker is executing
343
+        if not self.has_lock:
344
+            return
345
+        if (not ovn_conf.get_global_dhcpv4_opts() and
346
+                not ovn_conf.get_global_dhcpv6_opts()):
347
+            # No need to scan the subnets if the settings are unset.
348
+            raise periodics.NeverAgain()
349
+        LOG.debug('Maintenance task: Checking DHCP options on subnets')
350
+        self._sync_timer.restart()
351
+        fix_subnets = self._check_subnet_global_dhcp_opts()
352
+        if fix_subnets:
353
+            admin_context = n_context.get_admin_context()
354
+            LOG.debug('Triggering update for %s subnets', len(fix_subnets))
355
+            for subnet in fix_subnets:
356
+                neutron_net = self._ovn_client._plugin.get_network(
357
+                    admin_context, subnet['network_id'])
358
+                try:
359
+                    self._ovn_client.update_subnet(subnet, neutron_net)
360
+                except Exception:
361
+                    LOG.exception('Failed to update subnet %s',
362
+                                  subnet['id'])
363
+
364
+        self._sync_timer.stop()
365
+        LOG.info('Maintenance task: DHCP options check finished '
366
+                 '(took %.2f seconds)', self._sync_timer.elapsed())
367
+
368
+        raise periodics.NeverAgain()

+ 27
- 0
networking_ovn/common/ovn_client.py View File

@@ -1422,6 +1422,29 @@ class OVNClient(object):
1422 1422
 
1423 1423
         return dhcp_options
1424 1424
 
1425
+    def _process_global_dhcp_opts(self, options, ip_version):
1426
+        if ip_version == 4:
1427
+            global_options = config.get_global_dhcpv4_opts()
1428
+        else:
1429
+            global_options = config.get_global_dhcpv6_opts()
1430
+
1431
+        for option, value in global_options.items():
1432
+            if option in ovn_const.GLOBAL_DHCP_OPTS_BLACKLIST[ip_version]:
1433
+                # This option is not allowed to be set with a global setting
1434
+                LOG.debug('DHCP option %s is not permitted to be set in '
1435
+                          'global options. This option will be ignored.')
1436
+                continue
1437
+            # If the value is null (i.e. config ntp_server:), treat it as
1438
+            # a request to remove the option
1439
+            if value:
1440
+                options[option] = value
1441
+            else:
1442
+                try:
1443
+                    del(options[option])
1444
+                except KeyError:
1445
+                    # Option not present, job done
1446
+                    pass
1447
+
1425 1448
     def _get_ovn_dhcpv4_opts(self, subnet, network, server_mac=None):
1426 1449
         metadata_port_ip = self._find_metadata_port_ip(
1427 1450
             n_context.get_admin_context(), subnet)
@@ -1479,6 +1502,8 @@ class OVNClient(object):
1479 1502
 
1480 1503
             options['classless_static_route'] = '{' + ', '.join(routes) + '}'
1481 1504
 
1505
+        self._process_global_dhcp_opts(options, ip_version=4)
1506
+
1482 1507
         return options
1483 1508
 
1484 1509
     def _get_ovn_dhcpv6_opts(self, subnet, server_id=None):
@@ -1496,6 +1521,8 @@ class OVNClient(object):
1496 1521
         if subnet.get('ipv6_address_mode') == const.DHCPV6_STATELESS:
1497 1522
             dhcpv6_opts[ovn_const.DHCPV6_STATELESS_OPT] = 'true'
1498 1523
 
1524
+        self._process_global_dhcp_opts(dhcpv6_opts, ip_version=6)
1525
+
1499 1526
         return dhcpv6_opts
1500 1527
 
1501 1528
     def _remove_subnet_dhcp_options(self, subnet_id, txn):

+ 132
- 3
networking_ovn/tests/functional/test_maintenance.py View File

@@ -15,10 +15,12 @@
15 15
 
16 16
 import mock
17 17
 
18
+from futurist import periodics
18 19
 from neutron.tests.unit.api import test_extensions
19 20
 from neutron.tests.unit.extensions import test_extraroute
20 21
 from neutron.tests.unit.extensions import test_securitygroup
21 22
 
23
+from networking_ovn.common import config as ovn_config
22 24
 from networking_ovn.common import constants as ovn_const
23 25
 from networking_ovn.common import maintenance
24 26
 from networking_ovn.common import utils
@@ -97,13 +99,38 @@ class _TestMaintenanceHelper(base.TestOVNFunctionalBase):
97 99
                     ovn_const.OVN_PORT_NAME_EXT_ID_KEY) == name):
98 100
                 return row
99 101
 
100
-    def _create_subnet(self, name, net_id):
102
+    def _set_global_dhcp_opts(self, ip_version, opts):
103
+        opt_string = ','.join(['{0}:{1}'.format(key, value)
104
+                               for key, value
105
+                               in opts.items()])
106
+        if ip_version == 6:
107
+            ovn_config.cfg.CONF.set_override('ovn_dhcp6_global_options',
108
+                                             opt_string,
109
+                                             group='ovn')
110
+        if ip_version == 4:
111
+            ovn_config.cfg.CONF.set_override('ovn_dhcp4_global_options',
112
+                                             opt_string,
113
+                                             group='ovn')
114
+
115
+    def _unset_global_dhcp_opts(self, ip_version):
116
+        if ip_version == 6:
117
+            ovn_config.cfg.CONF.clear_override('ovn_dhcp6_global_options',
118
+                                               group='ovn')
119
+        if ip_version == 4:
120
+            ovn_config.cfg.CONF.clear_override('ovn_dhcp4_global_options',
121
+                                               group='ovn')
122
+
123
+    def _create_subnet(self, name, net_id, ip_version=4):
101 124
         data = {'subnet': {'name': name,
102 125
                            'tenant_id': self._tenant_id,
103 126
                            'network_id': net_id,
104
-                           'cidr': '10.0.0.0/24',
105
-                           'ip_version': 4,
127
+                           'ip_version': ip_version,
106 128
                            'enable_dhcp': True}}
129
+        if ip_version == 4:
130
+            data['subnet']['cidr'] = '10.0.0.0/24'
131
+        else:
132
+            data['subnet']['cidr'] = 'eef0::/64'
133
+
107 134
         req = self.new_create_request('subnets', data, self.fmt)
108 135
         res = req.get_response(self.api)
109 136
         return self.deserialize(self.fmt, res)['subnet']
@@ -325,6 +352,108 @@ class TestMaintenance(_TestMaintenanceHelper):
325 352
         # Assert the revision number no longer exists
326 353
         self.assertIsNone(db_rev.get_revision_row(neutron_obj['id']))
327 354
 
355
+    def test_subnet_global_dhcp4_opts(self):
356
+        obj_name = 'globaltestsubnet'
357
+        options = {'ntp_server': '1.2.3.4'}
358
+        neutron_net = self._create_network('network1')
359
+
360
+        # Create a subnet without global options
361
+        neutron_sub = self._create_subnet(obj_name, neutron_net['id'])
362
+
363
+        # Assert that the option is not set
364
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
365
+        self.assertIsNone(ovn_obj.options.get('ntp_server', None))
366
+
367
+        # Set some global DHCP Options
368
+        self._set_global_dhcp_opts(ip_version=4, opts=options)
369
+
370
+        # Run the maintenance task to add the new options
371
+        self.assertRaises(periodics.NeverAgain,
372
+                          self.maint.check_global_dhcp_opts)
373
+
374
+        # Assert that the option was added
375
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
376
+        self.assertEqual(
377
+            ovn_obj.options.get('ntp_server', None),
378
+            '1.2.3.4')
379
+
380
+        # Change the global option
381
+        new_options = {'ntp_server': '4.3.2.1'}
382
+        self._set_global_dhcp_opts(ip_version=4, opts=new_options)
383
+
384
+        # Run the maintenance task to update the options
385
+        self.assertRaises(periodics.NeverAgain,
386
+                          self.maint.check_global_dhcp_opts)
387
+
388
+        # Assert that the option was changed
389
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
390
+        self.assertEqual(
391
+            ovn_obj.options.get('ntp_server', None),
392
+            '4.3.2.1')
393
+
394
+        # Change the global option to null
395
+        new_options = {'ntp_server': ''}
396
+        self._set_global_dhcp_opts(ip_version=4, opts=new_options)
397
+
398
+        # Run the maintenance task to update the options
399
+        self.assertRaises(periodics.NeverAgain,
400
+                          self.maint.check_global_dhcp_opts)
401
+
402
+        # Assert that the option was removed
403
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
404
+        self.assertIsNone(ovn_obj.options.get('ntp_server', None))
405
+
406
+    def test_subnet_global_dhcp6_opts(self):
407
+        obj_name = 'globaltestsubnet'
408
+        options = {'ntp_server': '1.2.3.4'}
409
+        neutron_net = self._create_network('network1')
410
+
411
+        # Create a subnet without global options
412
+        neutron_sub = self._create_subnet(obj_name, neutron_net['id'], 6)
413
+
414
+        # Assert that the option is not set
415
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
416
+        self.assertIsNone(ovn_obj.options.get('ntp_server', None))
417
+
418
+        # Set some global DHCP Options
419
+        self._set_global_dhcp_opts(ip_version=6, opts=options)
420
+
421
+        # Run the maintenance task to add the new options
422
+        self.assertRaises(periodics.NeverAgain,
423
+                          self.maint.check_global_dhcp_opts)
424
+
425
+        # Assert that the option was added
426
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
427
+        self.assertEqual(
428
+            ovn_obj.options.get('ntp_server', None),
429
+            '1.2.3.4')
430
+
431
+        # Change the global option
432
+        new_options = {'ntp_server': '4.3.2.1'}
433
+        self._set_global_dhcp_opts(ip_version=6, opts=new_options)
434
+
435
+        # Run the maintenance task to update the options
436
+        self.assertRaises(periodics.NeverAgain,
437
+                          self.maint.check_global_dhcp_opts)
438
+
439
+        # Assert that the option was changed
440
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
441
+        self.assertEqual(
442
+            ovn_obj.options.get('ntp_server', None),
443
+            '4.3.2.1')
444
+
445
+        # Change the global option to null
446
+        new_options = {'ntp_server': ''}
447
+        self._set_global_dhcp_opts(ip_version=6, opts=new_options)
448
+
449
+        # Run the maintenance task to update the options
450
+        self.assertRaises(periodics.NeverAgain,
451
+                          self.maint.check_global_dhcp_opts)
452
+
453
+        # Assert that the option was removed
454
+        ovn_obj = self._find_subnet_row_by_id(neutron_sub['id'])
455
+        self.assertIsNone(ovn_obj.options.get('ntp_server', None))
456
+
328 457
     def test_subnet(self):
329 458
         obj_name = 'subnettest'
330 459
         neutron_net = self._create_network('network1')

+ 69
- 0
networking_ovn/tests/unit/ml2/test_mech_driver.py View File

@@ -1725,6 +1725,75 @@ class TestOVNMechansimDriverDHCPOptions(OVNMechanismDriverTestCase):
1725 1725
             self._test_get_ovn_dhcp_options_helper(subnet, network,
1726 1726
                                                    expected_dhcp_options)
1727 1727
 
1728
+    def test_get_ovn_dhcp_options_with_global_options(self):
1729
+        ovn_config.cfg.CONF.set_override('ovn_dhcp4_global_options',
1730
+                                         'ntp_server:8.8.8.8,'
1731
+                                         'mtu:9000,'
1732
+                                         'wpad:',
1733
+                                         group='ovn')
1734
+
1735
+        subnet = {'id': 'foo-subnet', 'network_id': 'network-id',
1736
+                  'cidr': '10.0.0.0/24',
1737
+                  'ip_version': 4,
1738
+                  'enable_dhcp': True,
1739
+                  'gateway_ip': '10.0.0.1',
1740
+                  'dns_nameservers': ['7.7.7.7', '8.8.8.8'],
1741
+                  'host_routes': [{'destination': '20.0.0.4',
1742
+                                   'nexthop': '10.0.0.100'}]}
1743
+        network = {'id': 'network-id', 'mtu': 1400}
1744
+
1745
+        expected_dhcp_options = {'cidr': '10.0.0.0/24',
1746
+                                 'external_ids': {
1747
+                                     'subnet_id': 'foo-subnet',
1748
+                                     ovn_const.OVN_REV_NUM_EXT_ID_KEY: '1'}}
1749
+        expected_dhcp_options['options'] = {
1750
+            'server_id': subnet['gateway_ip'],
1751
+            'server_mac': '01:02:03:04:05:06',
1752
+            'lease_time': str(12 * 60 * 60),
1753
+            'mtu': '1400',
1754
+            'router': subnet['gateway_ip'],
1755
+            'ntp_server': '8.8.8.8',
1756
+            'dns_server': '{7.7.7.7, 8.8.8.8}',
1757
+            'classless_static_route':
1758
+            '{20.0.0.4,10.0.0.100, 0.0.0.0/0,10.0.0.1}'
1759
+        }
1760
+
1761
+        self._test_get_ovn_dhcp_options_helper(subnet, network,
1762
+                                               expected_dhcp_options)
1763
+        expected_dhcp_options['options']['server_mac'] = '11:22:33:44:55:66'
1764
+        self._test_get_ovn_dhcp_options_helper(subnet, network,
1765
+                                               expected_dhcp_options,
1766
+                                               service_mac='11:22:33:44:55:66')
1767
+
1768
+    def test_get_ovn_dhcp_options_with_global_options_ipv6(self):
1769
+        ovn_config.cfg.CONF.set_override('ovn_dhcp6_global_options',
1770
+                                         'ntp_server:8.8.8.8,'
1771
+                                         'server_id:01:02:03:04:05:04,'
1772
+                                         'wpad:',
1773
+                                         group='ovn')
1774
+
1775
+        subnet = {'id': 'foo-subnet', 'network_id': 'network-id',
1776
+                  'cidr': 'ae70::/24',
1777
+                  'ip_version': 6,
1778
+                  'enable_dhcp': True,
1779
+                  'dns_nameservers': ['7.7.7.7', '8.8.8.8']}
1780
+        network = {'id': 'network-id', 'mtu': 1400}
1781
+
1782
+        ext_ids = {'subnet_id': 'foo-subnet',
1783
+                   ovn_const.OVN_REV_NUM_EXT_ID_KEY: '1'}
1784
+        expected_dhcp_options = {
1785
+            'cidr': 'ae70::/24', 'external_ids': ext_ids,
1786
+            'options': {'server_id': '01:02:03:04:05:06',
1787
+                        'ntp_server': '8.8.8.8',
1788
+                        'dns_server': '{7.7.7.7, 8.8.8.8}'}}
1789
+
1790
+        self._test_get_ovn_dhcp_options_helper(subnet, network,
1791
+                                               expected_dhcp_options)
1792
+        expected_dhcp_options['options']['server_id'] = '11:22:33:44:55:66'
1793
+        self._test_get_ovn_dhcp_options_helper(subnet, network,
1794
+                                               expected_dhcp_options,
1795
+                                               service_mac='11:22:33:44:55:66')
1796
+
1728 1797
     def test_get_ovn_dhcp_options_ipv6_subnet(self):
1729 1798
         subnet = {'id': 'foo-subnet', 'network_id': 'network-id',
1730 1799
                   'cidr': 'ae70::/24',

+ 10
- 0
releasenotes/notes/ovn-global-dhcp-options-6a23e6a3619bba78.yaml View File

@@ -0,0 +1,10 @@
1
+---
2
+features:
3
+  - |
4
+    Added config options ovn_dhcp4_global_option and ovn_dhcp6_global_options.
5
+    These options allow configuring DHCP options that will be enforced on all
6
+    subnets controlled by networking_ovn.
7
+upgrade:
8
+  - |
9
+    If ovn_dhcp4_global_option or ovn_dhcp6_global_options is set, all
10
+    existing subnets will be checked and updated when Neutron is started.

Loading…
Cancel
Save