Merge ~chad.smith/cloud-init:feature/openstack-network-v2-multi-nic into cloud-init:master

Proposed by Chad Smith
Status: Work in progress
Proposed branch: ~chad.smith/cloud-init:feature/openstack-network-v2-multi-nic
Merge into: cloud-init:master
Diff against target: 526 lines (+356/-28)
5 files modified
cloudinit/cmd/devel/net_convert.py (+5/-5)
cloudinit/net/network_state.py (+89/-17)
cloudinit/net/sysconfig.py (+2/-2)
cloudinit/sources/helpers/openstack.py (+257/-1)
tests/unittests/test_datasource/test_configdrive.py (+3/-3)
Reviewer Review Type Date Requested Status
cloud-init Commiters Pending
Review via email: mp+372009@code.launchpad.net

Commit message

Work in progress for initial review:

openstack: return network v2 from parsed network_data.json

TODO: sort promotion of global dns

To post a comment you must log in.
Revision history for this message
Chad Smith (chad.smith) :

Unmerged commits

5fac7a8... by Chad Smith

wip: generate openstack v2 networking config

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/cmd/devel/net_convert.py b/cloudinit/cmd/devel/net_convert.py
2index 1ad7e0b..7b4ae3f 100755
3--- a/cloudinit/cmd/devel/net_convert.py
4+++ b/cloudinit/cmd/devel/net_convert.py
5@@ -81,12 +81,8 @@ def handle_args(name, args):
6 pre_ns = yaml.load(net_data)
7 if 'network' in pre_ns:
8 pre_ns = pre_ns.get('network')
9- if args.debug:
10- sys.stderr.write('\n'.join(
11- ["Input YAML",
12- yaml.dump(pre_ns, default_flow_style=False, indent=4), ""]))
13 elif args.kind == 'network_data.json':
14- pre_ns = openstack.convert_net_json(
15+ pre_ns = openstack.convert_net_json_v2(
16 json.loads(net_data), known_macs=known_macs)
17 elif args.kind == 'azure-imds':
18 pre_ns = azure.parse_network_config(json.loads(net_data))
19@@ -94,6 +90,10 @@ def handle_args(name, args):
20 config = ovf.Config(ovf.ConfigFile(args.network_data.name))
21 pre_ns = ovf.get_network_config_from_conf(config, False)
22
23+ if args.debug:
24+ sys.stderr.write('\n'.join(
25+ ["Input YAML",
26+ yaml.dump(pre_ns, default_flow_style=False, indent=4), ""]))
27 ns = network_state.parse_net_config_data(pre_ns)
28 if not ns:
29 raise RuntimeError("No valid network_state object created from"
30diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
31index c0c415d..a7454f7 100644
32--- a/cloudinit/net/network_state.py
33+++ b/cloudinit/net/network_state.py
34@@ -4,6 +4,7 @@
35 #
36 # This file is part of cloud-init. See LICENSE file for license information.
37
38+from collections import defaultdict
39 import copy
40 import functools
41 import logging
42@@ -159,6 +160,10 @@ class NetworkState(object):
43 return self._version
44
45 @property
46+ def _global_dns_counts(self):
47+ return self._network_state['global_dns_counts']
48+
49+ @property
50 def dns_nameservers(self):
51 try:
52 return self._network_state['dns']['nameservers']
53@@ -234,6 +239,10 @@ class NetworkStateInterpreter(object):
54 self._network_state = copy.deepcopy(self.initial_network_state)
55 self._network_state['config'] = config
56 self._parsed = False
57+ # Reference counters to promote to global
58+ self._global_dns_refs = {
59+ 'nameserver': defaultdict(list), 'search': defaultdict(list)}
60+ self._network_state['global_dns_refs'] = self._global_dns_refs
61
62 @property
63 def network_state(self):
64@@ -318,7 +327,7 @@ class NetworkStateInterpreter(object):
65 " command '%s'" % command_type)
66 try:
67 handler(self, command)
68- self._v2_common(command)
69+ self._maybe_promote_v2_common(command)
70 except InvalidCommand:
71 if not skip_broken:
72 raise
73@@ -326,6 +335,41 @@ class NetworkStateInterpreter(object):
74 LOG.warning("Skipping invalid command: %s", command,
75 exc_info=True)
76 LOG.debug(self.dump_network_state())
77+ # Post-process v2 dns promotions if needed
78+ # count interfaces with ip, compare unpromoted global dns
79+ self._cleanup_v2_common_from_interfaces()
80+
81+ def _cleanup_v2_common_from_interfaces(self):
82+ """Strip any promoted global dns/search from specific interfaces."""
83+ interfaces = self._network_state.get('interfaces')
84+ global_dns = set(self._network_state['dns'].get('nameservers', []))
85+ global_search = set(self._network_state['dns'].get('search', []))
86+ dns_refs = self._global_dns_refs['nameserver']
87+ search_refs = self._global_dns_refs['search']
88+ promoted_dns = global_dns.intersection(dns_refs)
89+ promoted_search = global_dns.intersection(search_refs)
90+ for intf_name, intf_cfg in interfaces.items():
91+ for subnet in intf_cfg['subnets']:
92+ promote_dns = bool(not promoted_dns and len(interfaces) == 1)
93+ subnet_dns = subnet.get('dns_nameservers', [])
94+ if promote_dns and (subnet_dns or subnet.get('dns_search')):
95+ name_cmd = {'type': 'nameserver',
96+ 'search': subnet.get('dns_search', []),
97+ 'address': subnet_dns}
98+ self.handle_nameserver(name_cmd)
99+ subnet.pop('dns_search', None)
100+ subnet.pop('dns_nameservers', None)
101+ continue
102+ for dns_ip in subnet_dns:
103+ if dns_ip in promoted_dns:
104+ subnet['dns_nameservers'].remove(dns_ip)
105+ if not subnet['dns_nameservers']:
106+ subnet.pop('dns_nameservers')
107+ for search in subnet.get('dns_search', []):
108+ if search in promoted_search:
109+ subnet['dns_search'].remove(search)
110+ if not subnet['dns_search']:
111+ subnet.pop('dns_search')
112
113 @ensure_command_keys(['name'])
114 def handle_loopback(self, command):
115@@ -372,7 +416,6 @@ class NetworkStateInterpreter(object):
116 'subnets': subnets,
117 })
118 self._network_state['interfaces'].update({command.get('name'): iface})
119- self.dump_network_state()
120
121 @ensure_command_keys(['name', 'vlan_id', 'vlan_link'])
122 def handle_vlan(self, command):
123@@ -520,13 +563,15 @@ class NetworkStateInterpreter(object):
124 if not type(addrs) == list:
125 addrs = [addrs]
126 for addr in addrs:
127- dns['nameservers'].append(addr)
128+ if addr not in dns['nameservers']:
129+ dns['nameservers'].append(addr)
130 if 'search' in command:
131 paths = command['search']
132 if not isinstance(paths, list):
133 paths = [paths]
134 for path in paths:
135- dns['search'].append(path)
136+ if path not in dns['search']:
137+ dns['search'].append(path)
138
139 @ensure_command_keys(['destination'])
140 def handle_route(self, command):
141@@ -689,18 +734,45 @@ class NetworkStateInterpreter(object):
142 LOG.warning('Wifi configuration is only available to distros with'
143 'netplan rendering support.')
144
145- def _v2_common(self, cfg):
146- LOG.debug('v2_common: handling config:\n%s', cfg)
147- if 'nameservers' in cfg:
148- search = cfg.get('nameservers').get('search', [])
149- dns = cfg.get('nameservers').get('addresses', [])
150- name_cmd = {'type': 'nameserver'}
151- if len(search) > 0:
152- name_cmd.update({'search': search})
153- if len(dns) > 0:
154- name_cmd.update({'addresses': dns})
155- LOG.debug('v2(nameserver) -> v1(nameserver):\n%s', name_cmd)
156- self.handle_nameserver(name_cmd)
157+ def _maybe_promote_v2_common(self, cfg):
158+ """Possibly promote v2 common/global services from specific devices.
159+
160+ Since network v2 only supports per-interface DNS config settings, there
161+ is no 'global' dns service that can be expressed, unless we set
162+ the same dns values on every interface. If v2 config has the same
163+ dns config on every configured interface, it will be assumed that
164+ the common dns setting needs to be written to the distribution's
165+ 'global (read /etc/resolv.conf)' dns config.
166+
167+ Track reference counts in _global_dns_refs so net/sysconfig renderer
168+ can determine whether to use /etc/resolv.conf of not for specific
169+ device dns configuration.
170+ """
171+ LOG.debug('maybe_promote_v2_common: handling config:\n%s', cfg)
172+ for if_name, iface_cfg in cfg.items():
173+ if 'nameservers' in iface_cfg:
174+ search = iface_cfg.get('nameservers').get('search', [])
175+ if not search:
176+ search = []
177+ elif not isinstance(search, list):
178+ search = [search]
179+ dns = iface_cfg.get('nameservers').get('addresses')
180+ if not dns:
181+ dns = []
182+ elif not isinstance(dns, list):
183+ dns = [dns]
184+ name_cmd = {'type': 'nameserver', 'search': [], 'address': []}
185+ for sname in search:
186+ if self._global_dns_refs['search'][sname]:
187+ name_cmd['search'].append[sname]
188+ self._global_dns_refs['search'][sname].append(if_name)
189+ for dns_ip in dns:
190+ if self._global_dns_refs['nameserver'][dns_ip]:
191+ name_cmd['address'].append[dns_ip]
192+ self._global_dns_refs['nameserver'][dns_ip].append(if_name)
193+ if any([name_cmd['search'], name_cmd['address']]):
194+ # promote DNS config seen by multiple interfaces
195+ self.handle_nameserver(name_cmd)
196
197 def _handle_bond_bridge(self, command, cmd_type=None):
198 """Common handler for bond and bridge types"""
199@@ -827,7 +899,7 @@ def _normalize_net_keys(network, address_keys=()):
200
201 @returns: A dict containing normalized prefix and matching addr_key.
202 """
203- net = dict((k, v) for k, v in network.items() if v)
204+ net = dict((k, v) for k, v in network.items() if v is not None)
205 addr_key = None
206 for key in address_keys:
207 if net.get(key):
208diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
209index be5dede..0fa0508 100644
210--- a/cloudinit/net/sysconfig.py
211+++ b/cloudinit/net/sysconfig.py
212@@ -444,9 +444,9 @@ class Renderer(renderer.Renderer):
213
214 if _is_default_route(route):
215 if (
216- (subnet.get('ipv4') and
217+ (not is_ipv6 and
218 route_cfg.has_set_default_ipv4) or
219- (subnet.get('ipv6') and
220+ (is_ipv6 and
221 route_cfg.has_set_default_ipv6)
222 ):
223 raise ValueError("Duplicate declaration of default "
224diff --git a/cloudinit/sources/helpers/openstack.py b/cloudinit/sources/helpers/openstack.py
225index 8f06911..ceaf868 100644
226--- a/cloudinit/sources/helpers/openstack.py
227+++ b/cloudinit/sources/helpers/openstack.py
228@@ -17,6 +17,7 @@ import six
229 from cloudinit import ec2_utils
230 from cloudinit import log as logging
231 from cloudinit import net
232+from cloudinit.net import network_state
233 from cloudinit import sources
234 from cloudinit import url_helper
235 from cloudinit import util
236@@ -82,6 +83,36 @@ KNOWN_PHYSICAL_TYPES = (
237 'vif',
238 )
239
240+LINK_TYPE_TO_NETWORK_V2_KEYS = {
241+ 'bond': 'bonds',
242+ 'vlan': 'vlans',
243+}
244+
245+NET_V2_PHYSICAL_TYPES = ['ethernets', 'vlans', 'bonds', 'bridges', 'wifi']
246+
247+NETWORK_DATA_TO_V2 = {
248+ 'bond': {
249+ 'mtu': 'mtu',
250+ 'bond_links': 'interfaces',
251+ 'ethernet_mac_address': 'macaddress',
252+ },
253+ 'ethernets': {
254+ 'mtu': 'mtu',
255+ 'ethernet_mac_address': 'match.macaddress',
256+ 'id': 'set-name',
257+ },
258+ 'vlan': {
259+ 'key_rename': '{link}.{id}', # override top level key for object
260+ 'mtu': 'mtu',
261+ 'vlan_mac_address': 'macaddress',
262+ 'vlan_id': 'id',
263+ 'vlan_link': 'link',
264+ },
265+}
266+
267+# network_data.json keys for which converted net v2 values should be lowercase
268+LOWERCASE_KEY_VALUE_V2 = ('macaddress',)
269+
270
271 class NonReadable(IOError):
272 pass
273@@ -496,8 +527,233 @@ class MetadataReader(BaseReader):
274 retries=self.retries)
275
276
277-# Convert OpenStack ConfigDrive NetworkData json to network_config yaml
278+def _find_v2_device_type(name, net_v2):
279+ """Return the netv2 physical device type containing matching name."""
280+ for device_type in NET_V2_PHYSICAL_TYPES:
281+ if name in net_v2.get(device_type, {}):
282+ return device_type
283+ return None
284+
285+
286+def _convert_network_json_network_to_net_v2(src_json):
287+ """Parse a single network item from the networks list in network_data.json
288+
289+ @param src_json: One network item from network_data.json 'networks' key.
290+
291+ @return: Tuple of <interface_name>, network v2 configuration dict for the
292+ src_json. For example: eth0, {'addresses': [...], 'dhcp4': True}
293+ """
294+ net_v2 = {'addresses': []}
295+ ignored_keys = set()
296+
297+ # In Liberty spec https://specs.openstack.org/openstack/nova-specs/
298+ # specs/liberty/implemented/metadata-service-network-info.html
299+ if src_json['type'] == 'ipv4_dhcp':
300+ net_v2['dhcp4'] = True
301+ elif src_json['type'] == 'ipv6_dhcp':
302+ net_v2['dhcp6'] = True
303+
304+ for service in src_json.get('services', []):
305+ if service['type'] != 'dns':
306+ ignored_keys.update(['services.type(%s)' % service['type']])
307+ continue
308+ if 'nameservers' not in net_v2:
309+ net_v2['nameservers'] = {'addresses': [], 'search': []}
310+ net_v2['nameservers']['addresses'].append(service['address'])
311+ # In Rocky spec https://specs.openstack.org/openstack/nova-specs/specs/
312+ # rocky/approved/multiple-fixed-ips-network-information.html
313+ dns_nameservers = src_json.get('dns_nameservers', [])
314+ if dns_nameservers:
315+ if 'nameservers' not in net_v2:
316+ net_v2['nameservers'] = {'addresses': [], 'search': []}
317+ net_v2['nameservers']['addresses'] = copy.copy(dns_nameservers)
318+
319+ # Parse routes for network, prefix and gateway
320+ route_keys = set(['netmask', 'network', 'gateway'])
321+ for route in src_json.get('routes', []):
322+ ignored_route_keys = (set(route.keys()).difference(route_keys))
323+ ignored_keys.update(['route.%s' % key for key in ignored_route_keys])
324+ route_cfg = {
325+ 'to': '{network}/{prefix}'.format(
326+ network=route['network'],
327+ prefix=net.network_state.mask_to_net_prefix(route['netmask'])),
328+ 'via': route['gateway']}
329+ if route.get('metric'):
330+ route_cfg['metric'] = route.get('metric')
331+ if 'routes' not in net_v2:
332+ net_v2['routes'] = []
333+ net_v2['routes'].append(route_cfg)
334+
335+ # Parse ip addresses on Rocky and Liberty
336+ for ip_cfg in src_json.get('ip_addresses', []):
337+ if ip_cfg.get('netmask'):
338+ prefix = net.network_state.mask_to_net_prefix(ip_cfg['netmask'])
339+ cidr_fmt = '{ip}/{prefix}'
340+ else:
341+ cidr_fmt = '{ip}'
342+ prefix = None
343+ net_v2['addresses'].append(
344+ cidr_fmt.format(ip=ip_cfg['address'], prefix=prefix))
345+ liberty_ip = src_json.get('ip_address')
346+ if liberty_ip:
347+ if src_json.get('netmask'):
348+ prefix = net.network_state.mask_to_net_prefix(src_json['netmask'])
349+ cidr_fmt = '{ip}/{prefix}'
350+ else:
351+ cidr_fmt = '{ip}'
352+ prefix = None
353+ liberty_cidr = cidr_fmt.format(ip=liberty_ip, prefix=prefix)
354+ if liberty_cidr not in net_v2['addresses']:
355+ net_v2['addresses'].append(liberty_cidr)
356+ if not net_v2['addresses']:
357+ net_v2.pop('addresses')
358+ if ignored_keys:
359+ LOG.debug(
360+ 'Ignoring the network_data.json %s config keys %s',
361+ src_json['id'], ', '.join(ignored_keys))
362+ return src_json['link'], net_v2
363+
364+
365+def _convert_network_json_to_net_v2(src_json, var_map):
366+ """Return network v2 for an element of OpenStack NetworkData json.
367+
368+ @param src_json: Dict of network_data.json for a single src_json object
369+ @param var_map: Dict with a variable name map from network_data.json to
370+ network v2
371+
372+ @return Tuple of the interface name and the converted network v2 for the
373+ src_json object. For example: eth0, {'match': {'macaddress': 'AA:BB'}}
374+ """
375+ net_v2 = {}
376+ # Map openstack bond keys to network v2
377+ # Copy key values
378+ current_keys = set(src_json)
379+ for key in current_keys.intersection(set(var_map)):
380+ keyparts = var_map[key].split('.')
381+ tmp_cfg = net_v2 # allow traversing net_v2 dict
382+ while keyparts:
383+ keypart = keyparts.pop(0)
384+ if keyparts:
385+ if keypart not in tmp_cfg:
386+ tmp_cfg[keypart] = {}
387+ tmp_cfg = tmp_cfg[keypart]
388+ elif isinstance(src_json[key], list):
389+ tmp_cfg[keypart] = copy.copy(src_json[key])
390+ elif src_json[key]:
391+ if keypart in LOWERCASE_KEY_VALUE_V2:
392+ tmp_cfg[keypart] = src_json[key].lower()
393+ else:
394+ tmp_cfg[keypart] = src_json[key]
395+ if 'key_rename' in var_map:
396+ net_v2['key_rename'] = var_map['key_rename'].format(**net_v2)
397+ return src_json['id'], net_v2
398+
399+
400 def convert_net_json(network_json=None, known_macs=None):
401+ """Parse OpenStack ConfigDrive NetworkData json, returning network cfg v2.
402+
403+ OpenStack network_data.json provides a 3 element dictionary
404+ - "links" (links are network devices, physical or virtual)
405+ - "networks" (networks are ip network configurations for one or more
406+ links)
407+ - services (non-ip services, like dns)
408+
409+ networks and links are combined via network items referencing specific
410+ links via a 'link_id' which maps to a links 'id' field.
411+ """
412+ if network_json is None:
413+ return None
414+ net_config = {'version': 2}
415+ for link in network_json.get('links', []):
416+ link_type = link['type']
417+ v2_key = LINK_TYPE_TO_NETWORK_V2_KEYS.get(link_type)
418+ if not v2_key:
419+ v2_key = 'ethernets'
420+ if link_type not in KNOWN_PHYSICAL_TYPES:
421+ LOG.warning('Unknown network_data link type (%s); treating as'
422+ ' physical ethernet', link_type)
423+ if v2_key not in net_config:
424+ net_config[v2_key] = {}
425+ var_map = copy.deepcopy(NETWORK_DATA_TO_V2.get(v2_key))
426+ if not var_map:
427+ var_map = copy.deepcopy(NETWORK_DATA_TO_V2.get(link_type))
428+
429+ # Add v2 config parameters map for this link_type if present
430+ if link_type in network_state.NET_CONFIG_TO_V2:
431+ var_map.update(dict(
432+ (k.replace('-', '_'), 'parameters.{v}'.format(v=v))
433+ for k, v in network_state.NET_CONFIG_TO_V2[link_type].items()))
434+ intf_id, intf_cfg = _convert_network_json_to_net_v2(link, var_map)
435+ if v2_key in ('ethernets', 'bonds') and 'name' not in intf_cfg:
436+ if known_macs is None:
437+ known_macs = net.get_interfaces_by_mac()
438+ lower_known_macs = dict(
439+ (k.lower(), v) for k, v in known_macs.items())
440+ mac = intf_cfg.get( # top-level macaddress and match.macaddress
441+ 'macaddress', intf_cfg.get('match', {}).get('macaddress'))
442+ if not mac:
443+ raise ValueError("No mac_address or name entry for %s" % d)
444+ mac = mac.lower()
445+ if mac not in lower_known_macs:
446+ raise ValueError(
447+ 'Unable to find a system nic for %s' % mac)
448+ intf_cfg['key_rename'] = lower_known_macs[mac]
449+ net_config[v2_key].update({intf_id: intf_cfg})
450+ for network in network_json.get('networks', []):
451+ v2_key = _find_v2_device_type(network['link'], net_config)
452+ intf_id, network_cfg = _convert_network_json_network_to_net_v2(network)
453+ for key, val in network_cfg.items():
454+ if isinstance(val, list):
455+ if key not in net_config[v2_key][intf_id]:
456+ net_config[v2_key][intf_id][key] = []
457+ net_config[v2_key][intf_id][key].extend(val)
458+ else:
459+ net_config[v2_key][intf_id][key] = val
460+
461+ # Inject global nameserver values under each all interface which
462+ # has addresses and do not already have a DNS configuration
463+ ignored_keys = set()
464+ global_dns = []
465+ for service in network_json.get('services', []):
466+ if service['type'] != 'dns':
467+ ignored_keys.update('services.type(%s)' % service['type'])
468+ continue
469+ global_dns.append(service['address'])
470+
471+ # Handle renames and global_dns
472+ for dev_type in NET_V2_PHYSICAL_TYPES:
473+ if dev_type not in net_config:
474+ continue
475+ renames = {}
476+ for dev in net_config[dev_type]:
477+ renames[dev] = net_config[dev_type][dev].pop('key_rename', None)
478+ if not global_dns:
479+ continue
480+ dev_keys = set(net_config[dev_type][dev].keys())
481+ if set(['nameservers', 'dhcp4', 'dhcp6']).intersection(dev_keys):
482+ # Do not add nameservers if we already have dns config
483+ continue
484+ if 'addresses' not in net_config[dev_type][dev]:
485+ # No configured address, needs no nameserver
486+ continue
487+ net_config[dev_type][dev]['nameservers'] = {
488+ 'addresses': copy.copy(global_dns), 'search': []}
489+ for dev, rename in renames.items():
490+ if rename:
491+ net_config[dev_type][rename] = net_config[dev_type].pop(dev)
492+ if 'set-name' in net_config[dev_type][rename]:
493+ net_config[dev_type][rename]['set-name'] = rename
494+
495+ if ignored_keys:
496+ LOG.debug(
497+ 'Ignoring the network_data.json config keys %s',
498+ ', '.join(ignored_keys))
499+
500+ return net_config
501+
502+
503+# Convert OpenStack ConfigDrive NetworkData json to network_config yaml
504+def convert_net_json1(network_json=None, known_macs=None):
505 """Return a dictionary of network_config by parsing provided
506 OpenStack ConfigDrive NetworkData json format
507
508diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py
509index 520c50f..f4181f5 100644
510--- a/tests/unittests/test_datasource/test_configdrive.py
511+++ b/tests/unittests/test_datasource/test_configdrive.py
512@@ -719,11 +719,11 @@ class TestConvertNetworkData(CiTestCase):
513 for link in my_netdata['links']:
514 link['ethernet_mac_address'] = link['ethernet_mac_address'].upper()
515
516+ import pdb; pdb.set_trace()
517 ncfg = openstack.convert_net_json(my_netdata, known_macs=KNOWN_MACS)
518 config_name2mac = {}
519- for n in ncfg['config']:
520- if n['type'] == 'physical':
521- config_name2mac[n['name']] = n['mac_address']
522+ for name, if_cfg in ncfg['ethernets'].items():
523+ config_name2mac[name] = if_cfg['match']['macaddress']
524
525 expected = {'nic0': 'fa:16:3e:05:30:fe', 'enp0s1': 'fa:16:3e:69:b0:58',
526 'enp0s2': 'fa:16:3e:d4:57:ad'}

Subscribers

People subscribed via source and target branches