cloud-init-dev team mailing list archive
-
cloud-init-dev team
-
Mailing list archive
-
Message #06593
[Merge] ~chad.smith/cloud-init:feature/openstack-network-v2-multi-nic into cloud-init:master
Chad Smith has proposed merging ~chad.smith/cloud-init:feature/openstack-network-v2-multi-nic into cloud-init:master.
Commit message:
Work in progress for initial review:
openstack: return network v2 from parsed network_data.json
TODO: sort promotion of global dns
Requested reviews:
cloud-init commiters (cloud-init-dev)
For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/372009
--
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:feature/openstack-network-v2-multi-nic into cloud-init:master.
diff --git a/cloudinit/cmd/devel/net_convert.py b/cloudinit/cmd/devel/net_convert.py
index 1ad7e0b..7b4ae3f 100755
--- a/cloudinit/cmd/devel/net_convert.py
+++ b/cloudinit/cmd/devel/net_convert.py
@@ -81,12 +81,8 @@ def handle_args(name, args):
pre_ns = yaml.load(net_data)
if 'network' in pre_ns:
pre_ns = pre_ns.get('network')
- if args.debug:
- sys.stderr.write('\n'.join(
- ["Input YAML",
- yaml.dump(pre_ns, default_flow_style=False, indent=4), ""]))
elif args.kind == 'network_data.json':
- pre_ns = openstack.convert_net_json(
+ pre_ns = openstack.convert_net_json_v2(
json.loads(net_data), known_macs=known_macs)
elif args.kind == 'azure-imds':
pre_ns = azure.parse_network_config(json.loads(net_data))
@@ -94,6 +90,10 @@ def handle_args(name, args):
config = ovf.Config(ovf.ConfigFile(args.network_data.name))
pre_ns = ovf.get_network_config_from_conf(config, False)
+ if args.debug:
+ sys.stderr.write('\n'.join(
+ ["Input YAML",
+ yaml.dump(pre_ns, default_flow_style=False, indent=4), ""]))
ns = network_state.parse_net_config_data(pre_ns)
if not ns:
raise RuntimeError("No valid network_state object created from"
diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
index c0c415d..a7454f7 100644
--- a/cloudinit/net/network_state.py
+++ b/cloudinit/net/network_state.py
@@ -4,6 +4,7 @@
#
# This file is part of cloud-init. See LICENSE file for license information.
+from collections import defaultdict
import copy
import functools
import logging
@@ -159,6 +160,10 @@ class NetworkState(object):
return self._version
@property
+ def _global_dns_counts(self):
+ return self._network_state['global_dns_counts']
+
+ @property
def dns_nameservers(self):
try:
return self._network_state['dns']['nameservers']
@@ -234,6 +239,10 @@ class NetworkStateInterpreter(object):
self._network_state = copy.deepcopy(self.initial_network_state)
self._network_state['config'] = config
self._parsed = False
+ # Reference counters to promote to global
+ self._global_dns_refs = {
+ 'nameserver': defaultdict(list), 'search': defaultdict(list)}
+ self._network_state['global_dns_refs'] = self._global_dns_refs
@property
def network_state(self):
@@ -318,7 +327,7 @@ class NetworkStateInterpreter(object):
" command '%s'" % command_type)
try:
handler(self, command)
- self._v2_common(command)
+ self._maybe_promote_v2_common(command)
except InvalidCommand:
if not skip_broken:
raise
@@ -326,6 +335,41 @@ class NetworkStateInterpreter(object):
LOG.warning("Skipping invalid command: %s", command,
exc_info=True)
LOG.debug(self.dump_network_state())
+ # Post-process v2 dns promotions if needed
+ # count interfaces with ip, compare unpromoted global dns
+ self._cleanup_v2_common_from_interfaces()
+
+ def _cleanup_v2_common_from_interfaces(self):
+ """Strip any promoted global dns/search from specific interfaces."""
+ interfaces = self._network_state.get('interfaces')
+ global_dns = set(self._network_state['dns'].get('nameservers', []))
+ global_search = set(self._network_state['dns'].get('search', []))
+ dns_refs = self._global_dns_refs['nameserver']
+ search_refs = self._global_dns_refs['search']
+ promoted_dns = global_dns.intersection(dns_refs)
+ promoted_search = global_dns.intersection(search_refs)
+ for intf_name, intf_cfg in interfaces.items():
+ for subnet in intf_cfg['subnets']:
+ promote_dns = bool(not promoted_dns and len(interfaces) == 1)
+ subnet_dns = subnet.get('dns_nameservers', [])
+ if promote_dns and (subnet_dns or subnet.get('dns_search')):
+ name_cmd = {'type': 'nameserver',
+ 'search': subnet.get('dns_search', []),
+ 'address': subnet_dns}
+ self.handle_nameserver(name_cmd)
+ subnet.pop('dns_search', None)
+ subnet.pop('dns_nameservers', None)
+ continue
+ for dns_ip in subnet_dns:
+ if dns_ip in promoted_dns:
+ subnet['dns_nameservers'].remove(dns_ip)
+ if not subnet['dns_nameservers']:
+ subnet.pop('dns_nameservers')
+ for search in subnet.get('dns_search', []):
+ if search in promoted_search:
+ subnet['dns_search'].remove(search)
+ if not subnet['dns_search']:
+ subnet.pop('dns_search')
@ensure_command_keys(['name'])
def handle_loopback(self, command):
@@ -372,7 +416,6 @@ class NetworkStateInterpreter(object):
'subnets': subnets,
})
self._network_state['interfaces'].update({command.get('name'): iface})
- self.dump_network_state()
@ensure_command_keys(['name', 'vlan_id', 'vlan_link'])
def handle_vlan(self, command):
@@ -520,13 +563,15 @@ class NetworkStateInterpreter(object):
if not type(addrs) == list:
addrs = [addrs]
for addr in addrs:
- dns['nameservers'].append(addr)
+ if addr not in dns['nameservers']:
+ dns['nameservers'].append(addr)
if 'search' in command:
paths = command['search']
if not isinstance(paths, list):
paths = [paths]
for path in paths:
- dns['search'].append(path)
+ if path not in dns['search']:
+ dns['search'].append(path)
@ensure_command_keys(['destination'])
def handle_route(self, command):
@@ -689,18 +734,45 @@ class NetworkStateInterpreter(object):
LOG.warning('Wifi configuration is only available to distros with'
'netplan rendering support.')
- def _v2_common(self, cfg):
- LOG.debug('v2_common: handling config:\n%s', cfg)
- if 'nameservers' in cfg:
- search = cfg.get('nameservers').get('search', [])
- dns = cfg.get('nameservers').get('addresses', [])
- name_cmd = {'type': 'nameserver'}
- if len(search) > 0:
- name_cmd.update({'search': search})
- if len(dns) > 0:
- name_cmd.update({'addresses': dns})
- LOG.debug('v2(nameserver) -> v1(nameserver):\n%s', name_cmd)
- self.handle_nameserver(name_cmd)
+ def _maybe_promote_v2_common(self, cfg):
+ """Possibly promote v2 common/global services from specific devices.
+
+ Since network v2 only supports per-interface DNS config settings, there
+ is no 'global' dns service that can be expressed, unless we set
+ the same dns values on every interface. If v2 config has the same
+ dns config on every configured interface, it will be assumed that
+ the common dns setting needs to be written to the distribution's
+ 'global (read /etc/resolv.conf)' dns config.
+
+ Track reference counts in _global_dns_refs so net/sysconfig renderer
+ can determine whether to use /etc/resolv.conf of not for specific
+ device dns configuration.
+ """
+ LOG.debug('maybe_promote_v2_common: handling config:\n%s', cfg)
+ for if_name, iface_cfg in cfg.items():
+ if 'nameservers' in iface_cfg:
+ search = iface_cfg.get('nameservers').get('search', [])
+ if not search:
+ search = []
+ elif not isinstance(search, list):
+ search = [search]
+ dns = iface_cfg.get('nameservers').get('addresses')
+ if not dns:
+ dns = []
+ elif not isinstance(dns, list):
+ dns = [dns]
+ name_cmd = {'type': 'nameserver', 'search': [], 'address': []}
+ for sname in search:
+ if self._global_dns_refs['search'][sname]:
+ name_cmd['search'].append[sname]
+ self._global_dns_refs['search'][sname].append(if_name)
+ for dns_ip in dns:
+ if self._global_dns_refs['nameserver'][dns_ip]:
+ name_cmd['address'].append[dns_ip]
+ self._global_dns_refs['nameserver'][dns_ip].append(if_name)
+ if any([name_cmd['search'], name_cmd['address']]):
+ # promote DNS config seen by multiple interfaces
+ self.handle_nameserver(name_cmd)
def _handle_bond_bridge(self, command, cmd_type=None):
"""Common handler for bond and bridge types"""
@@ -827,7 +899,7 @@ def _normalize_net_keys(network, address_keys=()):
@returns: A dict containing normalized prefix and matching addr_key.
"""
- net = dict((k, v) for k, v in network.items() if v)
+ net = dict((k, v) for k, v in network.items() if v is not None)
addr_key = None
for key in address_keys:
if net.get(key):
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
index be5dede..0fa0508 100644
--- a/cloudinit/net/sysconfig.py
+++ b/cloudinit/net/sysconfig.py
@@ -444,9 +444,9 @@ class Renderer(renderer.Renderer):
if _is_default_route(route):
if (
- (subnet.get('ipv4') and
+ (not is_ipv6 and
route_cfg.has_set_default_ipv4) or
- (subnet.get('ipv6') and
+ (is_ipv6 and
route_cfg.has_set_default_ipv6)
):
raise ValueError("Duplicate declaration of default "
diff --git a/cloudinit/sources/helpers/openstack.py b/cloudinit/sources/helpers/openstack.py
index 8f06911..1ff5019 100644
--- a/cloudinit/sources/helpers/openstack.py
+++ b/cloudinit/sources/helpers/openstack.py
@@ -17,6 +17,7 @@ import six
from cloudinit import ec2_utils
from cloudinit import log as logging
from cloudinit import net
+from cloudinit.net import network_state
from cloudinit import sources
from cloudinit import url_helper
from cloudinit import util
@@ -82,6 +83,33 @@ KNOWN_PHYSICAL_TYPES = (
'vif',
)
+LINK_TYPE_TO_NETWORK_V2_KEYS = {
+ 'bond': 'bonds',
+ 'vlan': 'vlans',
+}
+
+NET_V2_PHYSICAL_TYPES = ['ethernets', 'vlans', 'bonds', 'bridges', 'wifi']
+
+NETWORK_DATA_TO_V2 = {
+ 'bond': {
+ 'mtu': 'mtu',
+ 'bond_links': 'interfaces',
+ 'ethernet_mac_address': 'macaddress',
+ },
+ 'ethernets': {
+ 'mtu': 'mtu',
+ 'ethernet_mac_address': 'match.macaddress',
+ 'id': 'set-name',
+ },
+ 'vlan': {
+ 'key_rename': '{link}.{id}', # override top level key for object
+ 'mtu': 'mtu',
+ 'vlan_mac_address': 'macaddress',
+ 'vlan_id': 'id',
+ 'vlan_link': 'link',
+ },
+}
+
class NonReadable(IOError):
pass
@@ -496,8 +524,224 @@ class MetadataReader(BaseReader):
retries=self.retries)
-# Convert OpenStack ConfigDrive NetworkData json to network_config yaml
+def _find_v2_device_type(name, net_v2):
+ """Return the netv2 physical device type containing matching name."""
+ for device_type in NET_V2_PHYSICAL_TYPES:
+ if name in net_v2.get(device_type, {}):
+ return device_type
+ return None
+
+
+def _convert_network_json_network_to_net_v2(src_json):
+ """Parse a single network item from the networks list in network_data.json
+
+ @param src_json: One network item from network_data.json 'networks' key.
+
+ @return: Tuple of <interface_name>, network v2 configuration dict for the
+ src_json. For example: eth0, {'addresses': [...], 'dhcp4': True}
+ """
+ net_v2 = {'addresses': []}
+ ignored_keys = set()
+
+ # In Liberty spec https://specs.openstack.org/openstack/nova-specs/
+ # specs/liberty/implemented/metadata-service-network-info.html
+ if src_json['type'] == 'ipv4_dhcp':
+ net_v2['dhcp4'] = True
+ elif src_json['type'] == 'ipv6_dhcp':
+ net_v2['dhcp6'] = True
+
+ for service in src_json.get('services', []):
+ if service['type'] != 'dns':
+ ignored_keys.update(['services.type(%s)' % service['type']])
+ continue
+ if 'nameservers' not in net_v2:
+ net_v2['nameservers'] = {'addresses': [], 'search': []}
+ net_v2['nameservers']['addresses'].append(service['address'])
+ # In Rocky spec https://specs.openstack.org/openstack/nova-specs/specs/
+ # rocky/approved/multiple-fixed-ips-network-information.html
+ dns_nameservers = src_json.get('dns_nameservers', [])
+ if dns_nameservers:
+ if 'nameservers' not in net_v2:
+ net_v2['nameservers'] = {'addresses': [], 'search': []}
+ net_v2['nameservers']['addresses'] = copy.copy(dns_nameservers)
+
+ # Parse routes for network, prefix and gateway
+ route_keys = set(['netmask', 'network', 'gateway'])
+ for route in src_json.get('routes', []):
+ ignored_route_keys = (set(route.keys()).difference(route_keys))
+ ignored_keys.update(['route.%s' % key for key in ignored_route_keys])
+ route_cfg = {
+ 'to': '{network}/{prefix}'.format(
+ network=route['network'],
+ prefix=net.network_state.mask_to_net_prefix(route['netmask'])),
+ 'via': route['gateway']}
+ if route.get('metric'):
+ route_cfg['metric'] = route.get('metric')
+ if 'routes' not in net_v2:
+ net_v2['routes'] = []
+ net_v2['routes'].append(route_cfg)
+
+ # Parse ip addresses on Rocky and Liberty
+ for ip_cfg in src_json.get('ip_addresses', []):
+ if ip_cfg.get('netmask'):
+ prefix = net.network_state.mask_to_net_prefix(ip_cfg['netmask'])
+ cidr_fmt = '{ip}/{prefix}'
+ else:
+ cidr_fmt = '{ip}'
+ prefix = None
+ net_v2['addresses'].append(
+ cidr_fmt.format(ip=ip_cfg['address'], prefix=prefix))
+ liberty_ip = src_json.get('ip_address')
+ if liberty_ip:
+ if src_json.get('netmask'):
+ prefix = net.network_state.mask_to_net_prefix(src_json['netmask'])
+ cidr_fmt = '{ip}/{prefix}'
+ else:
+ cidr_fmt = '{ip}'
+ prefix = None
+ liberty_cidr = cidr_fmt.format(ip=liberty_ip, prefix=prefix)
+ if liberty_cidr not in net_v2['addresses']:
+ net_v2['addresses'].append(liberty_cidr)
+ if not net_v2['addresses']:
+ net_v2.pop('addresses')
+ if ignored_keys:
+ LOG.debug(
+ 'Ignoring the network_data.json %s config keys %s',
+ src_json['id'], ', '.join(ignored_keys))
+ return src_json['link'], net_v2
+
+
+def _convert_network_json_to_net_v2(src_json, var_map):
+ """Return network v2 for an element of OpenStack NetworkData json.
+
+ @param src_json: Dict of network_data.json for a single src_json object
+ @param var_map: Dict with a variable name map from network_data.json to
+ network v2
+
+ @return Tuple of the interface name and the converted network v2 for the
+ src_json object. For example: eth0, {'match': {'macaddress': 'AA:BB'}}
+ """
+ net_v2 = {}
+ # Map openstack bond keys to network v2
+ # Copy key values
+ current_keys = set(src_json)
+ for key in current_keys.intersection(set(var_map)):
+ keyparts = var_map[key].split('.')
+ tmp_cfg = net_v2 # allow traversing net_v2 dict
+ while keyparts:
+ keypart = keyparts.pop(0)
+ if keyparts:
+ if keypart not in tmp_cfg:
+ tmp_cfg[keypart] = {}
+ tmp_cfg = tmp_cfg[keypart]
+ elif isinstance(src_json[key], list):
+ tmp_cfg[keypart] = copy.copy(src_json[key])
+ elif src_json[key]:
+ tmp_cfg[keypart] = src_json[key]
+ if 'key_rename' in var_map:
+ net_v2['key_rename'] = var_map['key_rename'].format(**net_v2)
+ return src_json['id'], net_v2
+
+
def convert_net_json(network_json=None, known_macs=None):
+ """Parse OpenStack ConfigDrive NetworkData json, returning network cfg v2.
+
+ OpenStack network_data.json provides a 3 element dictionary
+ - "links" (links are network devices, physical or virtual)
+ - "networks" (networks are ip network configurations for one or more
+ links)
+ - services (non-ip services, like dns)
+
+ networks and links are combined via network items referencing specific
+ links via a 'link_id' which maps to a links 'id' field.
+ """
+ if network_json is None:
+ return None
+ net_config = {'version': 2}
+ for link in network_json.get('links', []):
+ link_type = link['type']
+ v2_key = LINK_TYPE_TO_NETWORK_V2_KEYS.get(link_type)
+ if not v2_key:
+ v2_key = 'ethernets'
+ if link_type not in KNOWN_PHYSICAL_TYPES:
+ LOG.warning('Unknown network_data link type (%s); treating as'
+ ' physical ethernet', link_type)
+ if v2_key not in net_config:
+ net_config[v2_key] = {}
+ var_map = copy.deepcopy(NETWORK_DATA_TO_V2.get(v2_key))
+ if not var_map:
+ var_map = copy.deepcopy(NETWORK_DATA_TO_V2.get(link_type))
+
+ # Add v2 config parameters map for this link_type if present
+ if link_type in network_state.NET_CONFIG_TO_V2:
+ var_map.update(dict(
+ (k.replace('-', '_'), 'parameters.{v}'.format(v=v))
+ for k, v in network_state.NET_CONFIG_TO_V2[link_type].items()))
+ intf_id, intf_cfg = _convert_network_json_to_net_v2(link, var_map)
+ if v2_key in ('ethernets', 'bonds') and 'name' not in intf_cfg:
+ if known_macs is None:
+ known_macs = net.get_interfaces_by_mac()
+ intf_mac = intf_cfg.get('macaddress')
+ if not intf_mac:
+ intf_mac = intf_cfg.get('match', {}).get('macaddress')
+ intf_cfg['key_rename'] = known_macs.get(
+ intf_mac, 'UNKNOWN_MAC:%s' % intf_mac)
+ net_config[v2_key].update({intf_id: intf_cfg})
+ for network in network_json.get('networks', []):
+ v2_key = _find_v2_device_type(network['link'], net_config)
+ intf_id, network_cfg = _convert_network_json_network_to_net_v2(network)
+ for key, val in network_cfg.items():
+ if isinstance(val, list):
+ if key not in net_config[v2_key][intf_id]:
+ net_config[v2_key][intf_id][key] = []
+ net_config[v2_key][intf_id][key].extend(val)
+ else:
+ net_config[v2_key][intf_id][key] = val
+
+ # Inject global nameserver values under each all interface which
+ # has addresses and do not already have a DNS configuration
+ ignored_keys = set()
+ global_dns = []
+ for service in network_json.get('services', []):
+ if service['type'] != 'dns':
+ ignored_keys.update('services.type(%s)' % service['type'])
+ continue
+ global_dns.append(service['address'])
+
+ # Handle renames and global_dns
+ for dev_type in NET_V2_PHYSICAL_TYPES:
+ if dev_type not in net_config:
+ continue
+ renames = {}
+ for dev in net_config[dev_type]:
+ renames[dev] = net_config[dev_type][dev].pop('key_rename', None)
+ if not global_dns:
+ continue
+ dev_keys = set(net_config[dev_type][dev].keys())
+ if set(['nameservers', 'dhcp4', 'dhcp6']).intersection(dev_keys):
+ # Do not add nameservers if we already have dns config
+ continue
+ if 'addresses' not in net_config[dev_type][dev]:
+ # No configured address, needs no nameserver
+ continue
+ net_config[dev_type][dev]['nameservers'] = {
+ 'addresses': copy.copy(global_dns), 'search': []}
+ for dev, rename in renames.items():
+ if rename:
+ net_config[dev_type][rename] = net_config[dev_type].pop(dev)
+ if 'set-name' in net_config[dev_type][rename]:
+ net_config[dev_type][rename]['set-name'] = rename
+
+ if ignored_keys:
+ LOG.debug(
+ 'Ignoring the network_data.json config keys %s',
+ ', '.join(ignored_keys))
+
+ return net_config
+
+
+# Convert OpenStack ConfigDrive NetworkData json to network_config yaml
+def convert_net_json1(network_json=None, known_macs=None):
"""Return a dictionary of network_config by parsing provided
OpenStack ConfigDrive NetworkData json format
References