Merge ~raharper/cloud-init:rebased-netconfig-v2-passthrough into cloud-init:master

Proposed by Ryan Harper
Status: Merged
Merged at revision: ef18b8ac4cf7e3dfd98830fbdb298380a192a0fc
Proposed branch: ~raharper/cloud-init:rebased-netconfig-v2-passthrough
Merge into: cloud-init:master
Diff against target: 1811 lines (+1443/-36)
10 files modified
cloudinit/distros/debian.py (+11/-5)
cloudinit/net/eni.py (+3/-12)
cloudinit/net/netplan.py (+380/-0)
cloudinit/net/network_state.py (+299/-9)
cloudinit/net/renderers.py (+3/-1)
cloudinit/net/sysconfig.py (+15/-5)
systemd/cloud-init.service (+1/-0)
tests/unittests/test_distros/test_netconfig.py (+347/-4)
tests/unittests/test_net.py (+301/-0)
tools/net-convert.py (+83/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Needs Fixing
cloud-init Commiters Pending
Review via email: mp+320291@code.launchpad.net

Description of the change

cloudinit.net: add network config v2 parsing and rendering

Network configuration version 2 format is implemented in a package
called netplan (nplan)[1] which allows consolidated network config
for multiple network controllers.

- Add a new netplan renderer
- Update default policy, placing eni and sysconfig first
  This requires explicit policy to enable netplan over eni
  on systems which have both (Yakkety, Zesty, UC16)
- Allow any network state (parsed from any format cloud-init supports) to
  render to v2 if system supports netplan.
- Move eni's _subnet_is_ipv6 to common code for use by other renderers
- Make sysconfig renderer always emit /etc/syconfig/network configuration
- Update cloud-init.service systemd unit to also wait on systemd-networkd-wait-online.service

1. https://lists.ubuntu.com/archives/ubuntu-devel/2016-July/039464.html

To post a comment you must log in.
f006666... by Ryan Harper

(squash): eni.py whitespace damage

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

Ryan, this looks good, thanks.

I'll take a look first thing monday morning on it.

Revision history for this message
Scott Moser (smoser) wrote :

i have some small comments.
don't worry about fixing the 'handle_' comment, but i do worry about the confusion.

Revision history for this message
Ryan Harper (raharper) :
3ddea40... by Ryan Harper

unittests/test_distro/test_netconfig: pass 'system_info' dict into Distro class

2b63ec0... by Ryan Harper

distros/debian.py: update comment in _write_network method

Revision history for this message
Ryan Harper (raharper) wrote :

I'm mostly done fixing up the small issues; one larger discussion to be had on what to do about v2 passthrough and if we get v2 but need to render eni or sysconfig.

4ba0163... by Ryan Harper

net.sysconfig.py: Use _make_header() when writing sysconfig/network

9bcc80e... by Ryan Harper

Disable v2 passthrough, instead parse so we can render network config cross distro

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
9a10ab5... by Ryan Harper

network_state: v2 don't warn/error on missing mac_address in v2 input

1c1fc3a... by Ryan Harper

netplan: drop textwrap indent module, and reformat some comments/docstrings

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py
index 1101f02..3f0f9d5 100644
--- a/cloudinit/distros/debian.py
+++ b/cloudinit/distros/debian.py
@@ -42,11 +42,16 @@ NETWORK_CONF_FN = "/etc/network/interfaces.d/50-cloud-init.cfg"
42class Distro(distros.Distro):42class Distro(distros.Distro):
43 hostname_conf_fn = "/etc/hostname"43 hostname_conf_fn = "/etc/hostname"
44 locale_conf_fn = "/etc/default/locale"44 locale_conf_fn = "/etc/default/locale"
45 network_conf_fn = {
46 "eni": "/etc/network/interfaces.d/50-cloud-init.cfg",
47 "netplan": "/etc/netplan/50-cloud-init.yaml"
48 }
45 renderer_configs = {49 renderer_configs = {
46 'eni': {50 "eni": {"eni_path": network_conf_fn["eni"],
47 'eni_path': NETWORK_CONF_FN,51 "eni_header": ENI_HEADER},
48 'eni_header': ENI_HEADER,52 "netplan": {"netplan_path": network_conf_fn["netplan"],
49 }53 "netplan_header": ENI_HEADER,
54 "postcmds": True}
50 }55 }
5156
52 def __init__(self, name, cfg, paths):57 def __init__(self, name, cfg, paths):
@@ -75,7 +80,8 @@ class Distro(distros.Distro):
75 self.package_command('install', pkgs=pkglist)80 self.package_command('install', pkgs=pkglist)
7681
77 def _write_network(self, settings):82 def _write_network(self, settings):
78 util.write_file(NETWORK_CONF_FN, settings)83 # this is a legacy method, it will always write eni
84 util.write_file(self.network_conf_fn["eni"], settings)
79 return ['all']85 return ['all']
8086
81 def _write_network_config(self, netconfig):87 def _write_network_config(self, netconfig):
diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
index f471e05..9819d4f 100644
--- a/cloudinit/net/eni.py
+++ b/cloudinit/net/eni.py
@@ -8,6 +8,7 @@ import re
8from . import ParserError8from . import ParserError
99
10from . import renderer10from . import renderer
11from .network_state import subnet_is_ipv6
1112
12from cloudinit import util13from cloudinit import util
1314
@@ -111,16 +112,6 @@ def _iface_start_entry(iface, index, render_hwaddress=False):
111 return lines112 return lines
112113
113114
114def _subnet_is_ipv6(subnet):
115 # 'static6' or 'dhcp6'
116 if subnet['type'].endswith('6'):
117 # This is a request for DHCPv6.
118 return True
119 elif subnet['type'] == 'static' and ":" in subnet['address']:
120 return True
121 return False
122
123
124def _parse_deb_config_data(ifaces, contents, src_dir, src_path):115def _parse_deb_config_data(ifaces, contents, src_dir, src_path):
125 """Parses the file contents, placing result into ifaces.116 """Parses the file contents, placing result into ifaces.
126117
@@ -370,7 +361,7 @@ class Renderer(renderer.Renderer):
370 iface['mode'] = subnet['type']361 iface['mode'] = subnet['type']
371 iface['control'] = subnet.get('control', 'auto')362 iface['control'] = subnet.get('control', 'auto')
372 subnet_inet = 'inet'363 subnet_inet = 'inet'
373 if _subnet_is_ipv6(subnet):364 if subnet_is_ipv6(subnet):
374 subnet_inet += '6'365 subnet_inet += '6'
375 iface['inet'] = subnet_inet366 iface['inet'] = subnet_inet
376 if subnet['type'].startswith('dhcp'):367 if subnet['type'].startswith('dhcp'):
@@ -486,7 +477,7 @@ class Renderer(renderer.Renderer):
486def network_state_to_eni(network_state, header=None, render_hwaddress=False):477def network_state_to_eni(network_state, header=None, render_hwaddress=False):
487 # render the provided network state, return a string of equivalent eni478 # render the provided network state, return a string of equivalent eni
488 eni_path = 'etc/network/interfaces'479 eni_path = 'etc/network/interfaces'
489 renderer = Renderer({480 renderer = Renderer(config={
490 'eni_path': eni_path,481 'eni_path': eni_path,
491 'eni_header': header,482 'eni_header': header,
492 'links_path_prefix': None,483 'links_path_prefix': None,
diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
493new file mode 100644484new file mode 100644
index 0000000..7b3a6e8
--- /dev/null
+++ b/cloudinit/net/netplan.py
@@ -0,0 +1,380 @@
1# This file is part of cloud-init. See LICENSE file ...
2
3import copy
4import os
5
6from . import renderer
7from .network_state import subnet_is_ipv6
8
9from cloudinit import util
10from cloudinit.net import SYS_CLASS_NET, get_devicelist
11
12
13NET_CONFIG_TO_V2 = {
14 'bond': {'bond-ad-select': 'ad-select',
15 'bond-arp-interval': 'arp-interval',
16 'bond-arp-ip-target': 'arp-ip-target',
17 'bond-arp-validate': 'arp-validate',
18 'bond-downdelay': 'down-delay',
19 'bond-fail-over-mac': 'fail-over-mac-policy',
20 'bond-lacp-rate': 'lacp-rate',
21 'bond-miimon': 'mii-monitor-interval',
22 'bond-min-links': 'min-links',
23 'bond-mode': 'mode',
24 'bond-num-grat-arp': 'gratuitious-arp',
25 'bond-primary-reselect': 'primary-reselect-policy',
26 'bond-updelay': 'up-delay',
27 'bond-xmit_hash_policy': 'transmit_hash_policy'},
28 'bridge': {'bridge_ageing': 'ageing-time',
29 'bridge_bridgeprio': 'priority',
30 'bridge_fd': 'forward-delay',
31 'bridge_gcint': None,
32 'bridge_hello': 'hello-time',
33 'bridge_maxage': 'max-age',
34 'bridge_maxwait': None,
35 'bridge_pathcost': 'path-cost',
36 'bridge_portprio': None,
37 'bridge_waitport': None}}
38
39
40def indent(text, prefix):
41 lines = []
42 for line in text.splitlines(True):
43 lines.append(prefix + line)
44 return ''.join(lines)
45
46
47def _get_params_dict_by_match(config, match):
48 return dict((key, value) for (key, value) in config.items()
49 if key.startswith(match))
50
51
52def _extract_addresses(config, entry):
53 """This method parse a cloudinit.net.network_state dictionary (config) and
54 maps netstate keys/values into a dictionary (entry) to represent
55 netplan yaml.
56
57 An example config dictionary might look like:
58
59 {'mac_address': '52:54:00:12:34:00',
60 'name': 'interface0',
61 'subnets': [
62 {'address': '192.168.1.2/24',
63 'mtu': 1501,
64 'type': 'static'},
65 {'address': '2001:4800:78ff:1b:be76:4eff:fe06:1000",
66 'mtu': 1480,
67 'netmask': 64,
68 'type': 'static'}],
69 'type: physical'
70 }
71
72 An entry dictionary looks like:
73
74 {'set-name': 'interface0',
75 'match': {'macaddress': '52:54:00:12:34:00'},
76 'mtu': 1501}
77
78 After modification returns
79
80 {'set-name': 'interface0',
81 'match': {'macaddress': '52:54:00:12:34:00'},
82 'mtu': 1501,
83 'address': ['192.168.1.2/24', '2001:4800:78ff:1b:be76:4eff:fe06:1000"],
84 'mtu6': 1480}
85
86 """
87
88 def _listify(obj, token=' '):
89 "Helper to convert strings to list of strings, handle single string"
90 if not obj or type(obj) not in [str]:
91 return obj
92 if token in obj:
93 return obj.split(token)
94 else:
95 return [obj, ]
96
97 addresses = []
98 routes = []
99 nameservers = []
100 searchdomains = []
101 subnets = config.get('subnets', [])
102 if subnets is None:
103 subnets = []
104 for subnet in subnets:
105 sn_type = subnet.get('type')
106 if sn_type.startswith('dhcp'):
107 if sn_type == 'dhcp':
108 sn_type += '4'
109 entry.update({sn_type: True})
110 elif sn_type in ['static']:
111 addr = "%s" % subnet.get('address')
112 if 'netmask' in subnet:
113 addr += "/%s" % subnet.get('netmask')
114 if 'gateway' in subnet and subnet.get('gateway'):
115 gateway = subnet.get('gateway')
116 if ":" in gateway:
117 entry.update({'gateway6': gateway})
118 else:
119 entry.update({'gateway4': gateway})
120 if 'dns_nameservers' in subnet:
121 nameservers += _listify(subnet.get('dns_nameservers', []))
122 if 'dns_search' in subnet:
123 searchdomains += _listify(subnet.get('dns_search', []))
124 if 'mtu' in subnet:
125 mtukey = 'mtu'
126 if subnet_is_ipv6(subnet):
127 mtukey += '6'
128 entry.update({mtukey: subnet.get('mtu')})
129 for route in subnet.get('routes', []):
130 to_net = "%s/%s" % (route.get('network'),
131 route.get('netmask'))
132 route = {
133 'via': route.get('gateway'),
134 'to': to_net,
135 }
136 if 'metric' in route:
137 route.update({'metric': route.get('metric', 100)})
138 routes.append(route)
139
140 addresses.append(addr)
141
142 if len(addresses) > 0:
143 entry.update({'addresses': addresses})
144 if len(routes) > 0:
145 entry.update({'routes': routes})
146 if len(nameservers) > 0:
147 ns = {'addresses': nameservers}
148 entry.update({'nameservers': ns})
149 if len(searchdomains) > 0:
150 ns = entry.get('nameservers', {})
151 ns.update({'search': searchdomains})
152 entry.update({'nameservers': ns})
153
154
155def _extract_bond_slaves_by_name(interfaces, entry, bond_master):
156 bond_slave_names = sorted([name for (name, cfg) in interfaces.items()
157 if cfg.get('bond-master', None) == bond_master])
158 if len(bond_slave_names) > 0:
159 entry.update({'interfaces': bond_slave_names})
160
161
162class Renderer(renderer.Renderer):
163 """Renders network information in a /etc/netplan/network.yaml format."""
164
165 NETPLAN_GENERATE = ['netplan', 'generate']
166
167 def __init__(self, config=None):
168 if not config:
169 config = {}
170 self.netplan_path = config.get('netplan_path',
171 'etc/netplan/50-cloud-init.yaml')
172 self.netplan_header = config.get('netplan_header', None)
173 self._postcmds = config.get('postcmds', False)
174
175 def render_network_state(self, target, network_state):
176 # check network state for version
177 # if v2, then extract network_state.config
178 # else render_v2_from_state
179 fpnplan = os.path.join(target, self.netplan_path)
180 util.ensure_dir(os.path.dirname(fpnplan))
181 header = self.netplan_header if self.netplan_header else ""
182
183 # render from state
184 content = self._render_content(network_state)
185
186 # ensure we poke udev to run net_setup_link
187 if not header.endswith("\n"):
188 header += "\n"
189 util.write_file(fpnplan, header + content)
190
191 self._netplan_generate(run=self._postcmds)
192 self._net_setup_link(run=self._postcmds)
193
194 def _netplan_generate(self, run=False):
195 if not run:
196 print("netplan postcmd disabled")
197 return
198 util.subp(self.NETPLAN_GENERATE, capture=True)
199
200 def _net_setup_link(self, run=False):
201 """To ensure device link properties are applied, we poke
202 udev to re-evaluate networkd .link files and call
203 the setup_link udev builtin command
204 """
205 if not run:
206 print("netsetup postcmd disabled")
207 return
208 setup_lnk = ['udevadm', 'test-builtin', 'net_setup_link']
209 for cmd in [setup_lnk + [SYS_CLASS_NET + iface]
210 for iface in get_devicelist() if
211 os.path.islink(SYS_CLASS_NET + iface)]:
212 print(cmd)
213 util.subp(cmd, capture=True)
214
215 def _render_content(self, network_state):
216 print('rendering v2 for victory!')
217 ethernets = {}
218 wifis = {}
219 bridges = {}
220 bonds = {}
221 vlans = {}
222 content = []
223
224 interfaces = network_state._network_state.get('interfaces', [])
225
226 nameservers = network_state.dns_nameservers
227 searchdomains = network_state.dns_searchdomains
228
229 for config in network_state.iter_interfaces():
230 ifname = config.get('name')
231 # filter None entries up front so we can do simple if key in dict
232 ifcfg = dict((key, value) for (key, value) in config.items()
233 if value)
234
235 if_type = ifcfg.get('type')
236 if if_type == 'physical':
237 # required_keys = ['name', 'mac_address']
238 eth = {
239 'set-name': ifname,
240 'match': ifcfg.get('match', None),
241 }
242 if eth['match'] is None:
243 macaddr = ifcfg.get('mac_address', None)
244 if macaddr is not None:
245 eth['match'] = {'macaddress': macaddr.lower()}
246 else:
247 del eth['match']
248 del eth['set-name']
249 if 'mtu' in ifcfg:
250 eth['mtu'] = ifcfg.get('mtu')
251
252 _extract_addresses(ifcfg, eth)
253 ethernets.update({ifname: eth})
254
255 elif if_type == 'bond':
256 # required_keys = ['name', 'bond_interfaces']
257 bond = {}
258 bond_config = {}
259 # extract bond params and drop the bond_ prefix as it's
260 # redundent in v2 yaml format
261 v2_bond_map = NET_CONFIG_TO_V2.get('bond')
262 for match in ['bond_', 'bond-']:
263 bond_params = _get_params_dict_by_match(ifcfg, match)
264 for (param, value) in bond_params.items():
265 newname = v2_bond_map.get(param)
266 if newname is None:
267 continue
268 bond_config.update({newname: value})
269
270 if len(bond_config) > 0:
271 bond.update({'parameters': bond_config})
272 slave_interfaces = ifcfg.get('bond-slaves')
273 if slave_interfaces == 'none':
274 _extract_bond_slaves_by_name(interfaces, bond, ifname)
275 _extract_addresses(ifcfg, bond)
276 bonds.update({ifname: bond})
277
278 elif if_type == 'bridge':
279 # required_keys = ['name', 'bridge_ports']
280 ports = sorted(copy.copy(ifcfg.get('bridge_ports')))
281 bridge = {
282 'interfaces': ports,
283 }
284 # extract bridge params and drop the bridge prefix as it's
285 # redundent in v2 yaml format
286 match_prefix = 'bridge_'
287 params = _get_params_dict_by_match(ifcfg, match_prefix)
288 br_config = {}
289
290 # v2 yaml uses different names for the keys
291 # and at least one value format change
292 v2_bridge_map = NET_CONFIG_TO_V2.get('bridge')
293 for (param, value) in params.items():
294 newname = v2_bridge_map.get(param)
295 if newname is None:
296 continue
297 br_config.update({newname: value})
298 if newname == 'path-cost':
299 # <interface> <cost> -> <interface>: int(<cost>)
300 newvalue = {}
301 for costval in value:
302 (port, cost) = costval.split()
303 newvalue[port] = int(cost)
304 br_config.update({newname: newvalue})
305 if len(br_config) > 0:
306 bridge.update({'parameters': br_config})
307 _extract_addresses(ifcfg, bridge)
308 bridges.update({ifname: bridge})
309
310 elif if_type == 'vlan':
311 # required_keys = ['name', 'vlan_id', 'vlan-raw-device']
312 vlan = {
313 'id': ifcfg.get('vlan_id'),
314 'link': ifcfg.get('vlan-raw-device')
315 }
316
317 _extract_addresses(ifcfg, vlan)
318 vlans.update({ifname: vlan})
319
320 # inject global nameserver values under each physical interface
321 if nameservers:
322 for _eth, cfg in ethernets.items():
323 nscfg = cfg.get('nameservers', {})
324 addresses = nscfg.get('addresses', [])
325 addresses += nameservers
326 nscfg.update({'addresses': addresses})
327 cfg.update({'nameservers': nscfg})
328
329 if searchdomains:
330 for _eth, cfg in ethernets.items():
331 nscfg = cfg.get('nameservers', {})
332 search = nscfg.get('search', [])
333 search += searchdomains
334 nscfg.update({'search': search})
335 cfg.update({'nameservers': nscfg})
336
337 # workaround yaml dictionary key sorting when dumping
338 def _render_section(name, section):
339 if section:
340 dump = util.yaml_dumps({name: section},
341 explicit_start=False,
342 explicit_end=False)
343 txt = indent(dump, ' ' * 4)
344 return [txt]
345 return []
346
347 content.append("network:\n version: 2\n")
348 content += _render_section('ethernets', ethernets)
349 content += _render_section('wifis', wifis)
350 content += _render_section('bonds', bonds)
351 content += _render_section('bridges', bridges)
352 content += _render_section('vlans', vlans)
353
354 return "".join(content)
355
356
357def available(target=None):
358 expected = ['netplan']
359 search = ['/usr/sbin', '/sbin']
360 for p in expected:
361 if not util.which(p, search=search, target=target):
362 return False
363 return True
364
365
366def network_state_to_netplan(network_state, header=None):
367 # render the provided network state, return a string of equivalent eni
368 netplan_path = 'etc/network/50-cloud-init.yaml'
369 renderer = Renderer({
370 'netplan_path': netplan_path,
371 'netplan_header': header,
372 })
373 if not header:
374 header = ""
375 if not header.endswith("\n"):
376 header += "\n"
377 contents = renderer._render_content(network_state)
378 return header + contents
379
380# vi: ts=4 expandtab
diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
index 90b2835..c14aae2 100644
--- a/cloudinit/net/network_state.py
+++ b/cloudinit/net/network_state.py
@@ -1,4 +1,4 @@
1# Copyright (C) 2013-2014 Canonical Ltd.1# Copyright (C) 2017 Canonical Ltd.
2#2#
3# Author: Ryan Harper <ryan.harper@canonical.com>3# Author: Ryan Harper <ryan.harper@canonical.com>
4#4#
@@ -18,6 +18,10 @@ NETWORK_STATE_VERSION = 1
18NETWORK_STATE_REQUIRED_KEYS = {18NETWORK_STATE_REQUIRED_KEYS = {
19 1: ['version', 'config', 'network_state'],19 1: ['version', 'config', 'network_state'],
20}20}
21NETWORK_V2_KEY_FILTER = [
22 'addresses', 'dhcp4', 'dhcp6', 'gateway4', 'gateway6', 'interfaces',
23 'match', 'mtu', 'nameservers', 'renderer', 'set-name', 'wakeonlan'
24]
2125
2226
23def parse_net_config_data(net_config, skip_broken=True):27def parse_net_config_data(net_config, skip_broken=True):
@@ -26,11 +30,18 @@ def parse_net_config_data(net_config, skip_broken=True):
26 :param net_config: curtin network config dict30 :param net_config: curtin network config dict
27 """31 """
28 state = None32 state = None
29 if 'version' in net_config and 'config' in net_config:33 version = net_config.get('version')
30 nsi = NetworkStateInterpreter(version=net_config.get('version'),34 config = net_config.get('config')
31 config=net_config.get('config'))35 if version == 2:
36 # v2 does not have explicit 'config' key so we
37 # pass the whole net-config as-is
38 config = net_config
39
40 if version and config:
41 nsi = NetworkStateInterpreter(version=version, config=config)
32 nsi.parse_config(skip_broken=skip_broken)42 nsi.parse_config(skip_broken=skip_broken)
33 state = nsi.network_state43 state = nsi.get_network_state()
44
34 return state45 return state
3546
3647
@@ -106,6 +117,7 @@ class NetworkState(object):
106 def __init__(self, network_state, version=NETWORK_STATE_VERSION):117 def __init__(self, network_state, version=NETWORK_STATE_VERSION):
107 self._network_state = copy.deepcopy(network_state)118 self._network_state = copy.deepcopy(network_state)
108 self._version = version119 self._version = version
120 self.use_ipv6 = network_state.get('use_ipv6', False)
109121
110 @property122 @property
111 def version(self):123 def version(self):
@@ -152,7 +164,8 @@ class NetworkStateInterpreter(object):
152 'dns': {164 'dns': {
153 'nameservers': [],165 'nameservers': [],
154 'search': [],166 'search': [],
155 }167 },
168 'use_ipv6': False,
156 }169 }
157170
158 def __init__(self, version=NETWORK_STATE_VERSION, config=None):171 def __init__(self, version=NETWORK_STATE_VERSION, config=None):
@@ -165,6 +178,14 @@ class NetworkStateInterpreter(object):
165 def network_state(self):178 def network_state(self):
166 return NetworkState(self._network_state, version=self._version)179 return NetworkState(self._network_state, version=self._version)
167180
181 @property
182 def use_ipv6(self):
183 return self._network_state.get('use_ipv6')
184
185 @use_ipv6.setter
186 def use_ipv6(self, val):
187 self._network_state.update({'use_ipv6': val})
188
168 def dump(self):189 def dump(self):
169 state = {190 state = {
170 'version': self._version,191 'version': self._version,
@@ -192,8 +213,22 @@ class NetworkStateInterpreter(object):
192 def dump_network_state(self):213 def dump_network_state(self):
193 return util.yaml_dumps(self._network_state)214 return util.yaml_dumps(self._network_state)
194215
216 def as_dict(self):
217 return {'version': self.version, 'config': self.config}
218
219 def get_network_state(self):
220 ns = self.network_state
221 return ns
222
195 def parse_config(self, skip_broken=True):223 def parse_config(self, skip_broken=True):
196 # rebuild network state224 if self._version == 1:
225 self.parse_config_v1(skip_broken=skip_broken)
226 self._parsed = True
227 elif self._version == 2:
228 self.parse_config_v2(skip_broken=skip_broken)
229 self._parsed = True
230
231 def parse_config_v1(self, skip_broken=True):
197 for command in self._config:232 for command in self._config:
198 command_type = command['type']233 command_type = command['type']
199 try:234 try:
@@ -211,6 +246,26 @@ class NetworkStateInterpreter(object):
211 exc_info=True)246 exc_info=True)
212 LOG.debug(self.dump_network_state())247 LOG.debug(self.dump_network_state())
213248
249 def parse_config_v2(self, skip_broken=True):
250 for command_type, command in self._config.items():
251 if command_type == 'version':
252 continue
253 try:
254 handler = self.command_handlers[command_type]
255 except KeyError:
256 raise RuntimeError("No handler found for"
257 " command '%s'" % command_type)
258 try:
259 handler(self, command)
260 self._v2_common(command)
261 except InvalidCommand:
262 if not skip_broken:
263 raise
264 else:
265 LOG.warn("Skipping invalid command: %s", command,
266 exc_info=True)
267 LOG.debug(self.dump_network_state())
268
214 @ensure_command_keys(['name'])269 @ensure_command_keys(['name'])
215 def handle_loopback(self, command):270 def handle_loopback(self, command):
216 return self.handle_physical(command)271 return self.handle_physical(command)
@@ -238,11 +293,16 @@ class NetworkStateInterpreter(object):
238 if subnets:293 if subnets:
239 for subnet in subnets:294 for subnet in subnets:
240 if subnet['type'] == 'static':295 if subnet['type'] == 'static':
296 if ':' in subnet['address']:
297 self.use_ipv6 = True
241 if 'netmask' in subnet and ':' in subnet['address']:298 if 'netmask' in subnet and ':' in subnet['address']:
242 subnet['netmask'] = mask2cidr(subnet['netmask'])299 subnet['netmask'] = mask2cidr(subnet['netmask'])
243 for route in subnet.get('routes', []):300 for route in subnet.get('routes', []):
244 if 'netmask' in route:301 if 'netmask' in route:
245 route['netmask'] = mask2cidr(route['netmask'])302 route['netmask'] = mask2cidr(route['netmask'])
303 elif subnet['type'].endswith('6'):
304 self.use_ipv6 = True
305
246 iface.update({306 iface.update({
247 'name': command.get('name'),307 'name': command.get('name'),
248 'type': command.get('type'),308 'type': command.get('type'),
@@ -327,7 +387,7 @@ class NetworkStateInterpreter(object):
327 bond_if.update({param: val})387 bond_if.update({param: val})
328 self._network_state['interfaces'].update({ifname: bond_if})388 self._network_state['interfaces'].update({ifname: bond_if})
329389
330 @ensure_command_keys(['name', 'bridge_interfaces', 'params'])390 @ensure_command_keys(['name', 'bridge_interfaces'])
331 def handle_bridge(self, command):391 def handle_bridge(self, command):
332 '''392 '''
333 auto br0393 auto br0
@@ -373,7 +433,7 @@ class NetworkStateInterpreter(object):
373 self.handle_physical(command)433 self.handle_physical(command)
374 iface = interfaces.get(command.get('name'), {})434 iface = interfaces.get(command.get('name'), {})
375 iface['bridge_ports'] = command['bridge_interfaces']435 iface['bridge_ports'] = command['bridge_interfaces']
376 for param, val in command.get('params').items():436 for param, val in command.get('params', {}).items():
377 iface.update({param: val})437 iface.update({param: val})
378438
379 interfaces.update({iface['name']: iface})439 interfaces.update({iface['name']: iface})
@@ -407,6 +467,236 @@ class NetworkStateInterpreter(object):
407 }467 }
408 routes.append(route)468 routes.append(route)
409469
470 # V2 handlers
471 def handle_bonds(self, command):
472 '''
473 v2_command = {
474 bond0: {
475 'interfaces': ['interface0', 'interface1'],
476 'miimon': 100,
477 'mode': '802.3ad',
478 'xmit_hash_policy': 'layer3+4'},
479 bond1: {
480 'bond-slaves': ['interface2', 'interface7'],
481 'mode': 1
482 }
483 }
484
485 v1_command = {
486 'type': 'bond'
487 'name': 'bond0',
488 'bond_interfaces': [interface0, interface1],
489 'params': {
490 'bond-mode': '802.3ad',
491 'bond_miimon: 100,
492 'bond_xmit_hash_policy': 'layer3+4',
493 }
494 }
495
496 '''
497 self._handle_bond_bridge(command, cmd_type='bond')
498
499 def handle_bridges(self, command):
500
501 '''
502 v2_command = {
503 br0: {
504 'interfaces': ['interface0', 'interface1'],
505 'fd': 0,
506 'stp': 'off',
507 'maxwait': 0,
508 }
509 }
510
511 v1_command = {
512 'type': 'bridge'
513 'name': 'br0',
514 'bridge_interfaces': [interface0, interface1],
515 'params': {
516 'bridge_stp': 'off',
517 'bridge_fd: 0,
518 'bridge_maxwait': 0
519 }
520 }
521
522 '''
523 self._handle_bond_bridge(command, cmd_type='bridge')
524
525 def handle_ethernets(self, command):
526 '''
527 ethernets:
528 eno1:
529 match:
530 macaddress: 00:11:22:33:44:55
531 wakeonlan: true
532 dhcp4: true
533 dhcp6: false
534 addresses:
535 - 192.168.14.2/24
536 - 2001:1::1/64
537 gateway4: 192.168.14.1
538 gateway6: 2001:1::2
539 nameservers:
540 search: [foo.local, bar.local]
541 addresses: [8.8.8.8, 8.8.4.4]
542 lom:
543 match:
544 driver: ixgbe
545 set-name: lom1
546 dhcp6: true
547 switchports:
548 match:
549 name: enp2*
550 mtu: 1280
551
552 command = {
553 'type': 'physical',
554 'mac_address': 'c0:d6:9f:2c:e8:80',
555 'name': 'eth0',
556 'subnets': [
557 {'type': 'dhcp4'}
558 ]
559 }
560 '''
561 for eth, cfg in command.items():
562 phy_cmd = {
563 'type': 'physical',
564 'name': cfg.get('set-name', eth),
565 }
566
567 for key in ['mtu', 'match', 'wakeonlan']:
568 if key in cfg:
569 phy_cmd.update({key: cfg.get(key)})
570
571 subnets = self._v2_to_v1_ipcfg(cfg)
572 if len(subnets) > 0:
573 phy_cmd.update({'subnets': subnets})
574
575 LOG.debug('v2(ethernets) -> v1(physical):\n%s', phy_cmd)
576 self.handle_physical(phy_cmd)
577
578 def handle_vlans(self, command):
579 '''
580 v2_vlans = {
581 'eth0.123': {
582 'id': 123,
583 'link': 'eth0',
584 'dhcp4': True,
585 }
586 }
587
588 v1_command = {
589 'type': 'vlan',
590 'name': 'eth0.123',
591 'vlan_link': 'eth0',
592 'vlan_id': 123,
593 'subnets': [{'type': 'dhcp4'}],
594 }
595 '''
596 for vlan, cfg in command.items():
597 vlan_cmd = {
598 'type': 'vlan',
599 'name': vlan,
600 'vlan_id': cfg.get('id'),
601 'vlan_link': cfg.get('link'),
602 }
603 subnets = self._v2_to_v1_ipcfg(cfg)
604 if len(subnets) > 0:
605 vlan_cmd.update({'subnets': subnets})
606 LOG.debug('v2(vlans) -> v1(vlan):\n%s', vlan_cmd)
607 self.handle_vlan(vlan_cmd)
608
609 def handle_wifis(self, command):
610 raise NotImplemented('NetworkState V2: Skipping wifi configuration')
611
612 def _v2_common(self, cfg):
613 LOG.debug('v2_common: handling config:\n%s', cfg)
614 if 'nameservers' in cfg:
615 search = cfg.get('nameservers').get('search', [])
616 dns = cfg.get('nameservers').get('addresses', [])
617 name_cmd = {'type': 'nameserver'}
618 if len(search) > 0:
619 name_cmd.update({'search': search})
620 if len(dns) > 0:
621 name_cmd.update({'addresses': dns})
622 LOG.debug('v2(nameserver) -> v1(nameserver):\n%s', name_cmd)
623 self.handle_nameserver(name_cmd)
624
625 def _handle_bond_bridge(self, command, cmd_type=None):
626 """Common handler for bond and bridge types"""
627 for item_name, item_cfg in command.items():
628 item_params = dict((key, value) for (key, value) in
629 item_cfg.items() if key not in
630 NETWORK_V2_KEY_FILTER)
631 v1_cmd = {
632 'type': cmd_type,
633 'name': item_name,
634 cmd_type + '_interfaces': item_cfg.get('interfaces'),
635 'params': item_params,
636 }
637 subnets = self._v2_to_v1_ipcfg(item_cfg)
638 if len(subnets) > 0:
639 v1_cmd.update({'subnets': subnets})
640
641 LOG.debug('v2(%ss) -> v1(%s):\n%s', cmd_type, cmd_type, v1_cmd)
642 self.handle_bridge(v1_cmd)
643
644 def _v2_to_v1_ipcfg(self, cfg):
645 """Common ipconfig extraction from v2 to v1 subnets array."""
646
647 subnets = []
648 if 'dhcp4' in cfg:
649 subnets.append({'type': 'dhcp4'})
650 if 'dhcp6' in cfg:
651 self.use_ipv6 = True
652 subnets.append({'type': 'dhcp6'})
653
654 gateway4 = None
655 gateway6 = None
656 for address in cfg.get('addresses', []):
657 subnet = {
658 'type': 'static',
659 'address': address,
660 }
661
662 routes = []
663 for route in cfg.get('routes', []):
664 route_addr = route.get('to')
665 if "/" in route_addr:
666 route_addr, route_cidr = route_addr.split("/")
667 route_netmask = cidr2mask(route_cidr)
668 subnet_route = {
669 'address': route_addr,
670 'netmask': route_netmask,
671 'gateway': route.get('via')
672 }
673 routes.append(subnet_route)
674 if len(routes) > 0:
675 subnet.update({'routes': routes})
676
677 if ":" in address:
678 if 'gateway6' in cfg and gateway6 is None:
679 gateway6 = cfg.get('gateway6')
680 subnet.update({'gateway': gateway6})
681 else:
682 if 'gateway4' in cfg and gateway4 is None:
683 gateway4 = cfg.get('gateway4')
684 subnet.update({'gateway': gateway4})
685
686 subnets.append(subnet)
687 return subnets
688
689
690def subnet_is_ipv6(subnet):
691 """ Common helper for checking network_state subnets for ipv6"""
692 # 'static6' or 'dhcp6'
693 if subnet['type'].endswith('6'):
694 # This is a request for DHCPv6.
695 return True
696 elif subnet['type'] == 'static' and ":" in subnet['address']:
697 return True
698 return False
699
410700
411def cidr2mask(cidr):701def cidr2mask(cidr):
412 mask = [0, 0, 0, 0]702 mask = [0, 0, 0, 0]
diff --git a/cloudinit/net/renderers.py b/cloudinit/net/renderers.py
index 5ad8455..5117b4a 100644
--- a/cloudinit/net/renderers.py
+++ b/cloudinit/net/renderers.py
@@ -1,15 +1,17 @@
1# This file is part of cloud-init. See LICENSE file for license information.1# This file is part of cloud-init. See LICENSE file for license information.
22
3from . import eni3from . import eni
4from . import netplan
4from . import RendererNotFoundError5from . import RendererNotFoundError
5from . import sysconfig6from . import sysconfig
67
7NAME_TO_RENDERER = {8NAME_TO_RENDERER = {
8 "eni": eni,9 "eni": eni,
10 "netplan": netplan,
9 "sysconfig": sysconfig,11 "sysconfig": sysconfig,
10}12}
1113
12DEFAULT_PRIORITY = ["eni", "sysconfig"]14DEFAULT_PRIORITY = ["eni", "sysconfig", "netplan"]
1315
1416
15def search(priority=None, target=None, first=False):17def search(priority=None, target=None, first=False):
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
index 117b515..504e4d0 100644
--- a/cloudinit/net/sysconfig.py
+++ b/cloudinit/net/sysconfig.py
@@ -9,6 +9,7 @@ from cloudinit.distros.parsers import resolv_conf
9from cloudinit import util9from cloudinit import util
1010
11from . import renderer11from . import renderer
12from .network_state import subnet_is_ipv6
1213
1314
14def _make_header(sep='#'):15def _make_header(sep='#'):
@@ -194,7 +195,7 @@ class Renderer(renderer.Renderer):
194 def __init__(self, config=None):195 def __init__(self, config=None):
195 if not config:196 if not config:
196 config = {}197 config = {}
197 self.sysconf_dir = config.get('sysconf_dir', 'etc/sysconfig/')198 self.sysconf_dir = config.get('sysconf_dir', 'etc/sysconfig')
198 self.netrules_path = config.get(199 self.netrules_path = config.get(
199 'netrules_path', 'etc/udev/rules.d/70-persistent-net.rules')200 'netrules_path', 'etc/udev/rules.d/70-persistent-net.rules')
200 self.dns_path = config.get('dns_path', 'etc/resolv.conf')201 self.dns_path = config.get('dns_path', 'etc/resolv.conf')
@@ -220,7 +221,7 @@ class Renderer(renderer.Renderer):
220 iface_cfg['BOOTPROTO'] = 'dhcp'221 iface_cfg['BOOTPROTO'] = 'dhcp'
221 elif subnet_type == 'static':222 elif subnet_type == 'static':
222 iface_cfg['BOOTPROTO'] = 'static'223 iface_cfg['BOOTPROTO'] = 'static'
223 if subnet.get('ipv6'):224 if subnet_is_ipv6(subnet):
224 iface_cfg['IPV6ADDR'] = subnet['address']225 iface_cfg['IPV6ADDR'] = subnet['address']
225 iface_cfg['IPV6INIT'] = True226 iface_cfg['IPV6INIT'] = True
226 else:227 else:
@@ -390,19 +391,28 @@ class Renderer(renderer.Renderer):
390 return contents391 return contents
391392
392 def render_network_state(self, network_state, target=None):393 def render_network_state(self, network_state, target=None):
394 file_mode = 0o644
393 base_sysconf_dir = util.target_path(target, self.sysconf_dir)395 base_sysconf_dir = util.target_path(target, self.sysconf_dir)
394 for path, data in self._render_sysconfig(base_sysconf_dir,396 for path, data in self._render_sysconfig(base_sysconf_dir,
395 network_state).items():397 network_state).items():
396 util.write_file(path, data)398 util.write_file(path, data, file_mode)
397 if self.dns_path:399 if self.dns_path:
398 dns_path = util.target_path(target, self.dns_path)400 dns_path = util.target_path(target, self.dns_path)
399 resolv_content = self._render_dns(network_state,401 resolv_content = self._render_dns(network_state,
400 existing_dns_path=dns_path)402 existing_dns_path=dns_path)
401 util.write_file(dns_path, resolv_content)403 util.write_file(dns_path, resolv_content, file_mode)
402 if self.netrules_path:404 if self.netrules_path:
403 netrules_content = self._render_persistent_net(network_state)405 netrules_content = self._render_persistent_net(network_state)
404 netrules_path = util.target_path(target, self.netrules_path)406 netrules_path = util.target_path(target, self.netrules_path)
405 util.write_file(netrules_path, netrules_content)407 util.write_file(netrules_path, netrules_content, file_mode)
408
409 # always write /etc/sysconfig/network configuration
410 sysconfig_path = util.target_path(target, "etc/sysconfig/network")
411 netcfg = [_make_header(), 'NETWORKING=yes']
412 if network_state.use_ipv6:
413 netcfg.append('NETWORKING_IPV6=yes')
414 netcfg.append('IPV6_AUTOCONF=no')
415 util.write_file(sysconfig_path, "\n".join(netcfg) + "\n", file_mode)
406416
407417
408def available(target=None):418def available(target=None):
diff --git a/systemd/cloud-init.service b/systemd/cloud-init.service
index fb3b918..39acc20 100644
--- a/systemd/cloud-init.service
+++ b/systemd/cloud-init.service
@@ -5,6 +5,7 @@ Wants=cloud-init-local.service
5Wants=sshd-keygen.service5Wants=sshd-keygen.service
6Wants=sshd.service6Wants=sshd.service
7After=cloud-init-local.service7After=cloud-init-local.service
8After=systemd-networkd-wait-online.service
8After=networking.service9After=networking.service
9Before=network-online.target10Before=network-online.target
10Before=sshd-keygen.service11Before=sshd-keygen.service
diff --git a/tests/unittests/test_distros/test_netconfig.py b/tests/unittests/test_distros/test_netconfig.py
index bde3bb5..d187c9d 100644
--- a/tests/unittests/test_distros/test_netconfig.py
+++ b/tests/unittests/test_distros/test_netconfig.py
@@ -19,6 +19,7 @@ from cloudinit.distros.parsers.sys_conf import SysConf
19from cloudinit import helpers19from cloudinit import helpers
20from cloudinit import settings20from cloudinit import settings
21from cloudinit import util21from cloudinit import util
22from cloudinit.net import eni
2223
2324
24BASE_NET_CFG = '''25BASE_NET_CFG = '''
@@ -28,10 +29,10 @@ iface lo inet loopback
28auto eth029auto eth0
29iface eth0 inet static30iface eth0 inet static
30 address 192.168.1.531 address 192.168.1.5
31 netmask 255.255.255.0
32 network 192.168.0.0
33 broadcast 192.168.1.032 broadcast 192.168.1.0
34 gateway 192.168.1.25433 gateway 192.168.1.254
34 netmask 255.255.255.0
35 network 192.168.0.0
3536
36auto eth137auto eth1
37iface eth1 inet dhcp38iface eth1 inet dhcp
@@ -67,6 +68,100 @@ iface eth1 inet6 static
67 gateway 2607:f0d0:1002:0011::168 gateway 2607:f0d0:1002:0011::1
68'''69'''
6970
71V1_NET_CFG = {'config': [{'name': 'eth0',
72
73 'subnets': [{'address': '192.168.1.5',
74 'broadcast': '192.168.1.0',
75 'gateway': '192.168.1.254',
76 'netmask': '255.255.255.0',
77 'type': 'static'}],
78 'type': 'physical'},
79 {'name': 'eth1',
80 'subnets': [{'control': 'auto', 'type': 'dhcp4'}],
81 'type': 'physical'}],
82 'version': 1}
83
84V1_NET_CFG_OUTPUT = """
85# This file is generated from information provided by
86# the datasource. Changes to it will not persist across an instance.
87# To disable cloud-init's network configuration capabilities, write a file
88# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
89# network: {config: disabled}
90auto lo
91iface lo inet loopback
92
93auto eth0
94iface eth0 inet static
95 address 192.168.1.5
96 broadcast 192.168.1.0
97 gateway 192.168.1.254
98 netmask 255.255.255.0
99
100auto eth1
101iface eth1 inet dhcp
102"""
103
104V1_NET_CFG_IPV6 = {'config': [{'name': 'eth0',
105 'subnets': [{'address':
106 '2607:f0d0:1002:0011::2',
107 'gateway':
108 '2607:f0d0:1002:0011::1',
109 'netmask': '64',
110 'type': 'static'}],
111 'type': 'physical'},
112 {'name': 'eth1',
113 'subnets': [{'control': 'auto',
114 'type': 'dhcp4'}],
115 'type': 'physical'}],
116 'version': 1}
117
118
119V1_TO_V2_NET_CFG_OUTPUT = """
120# This file is generated from information provided by
121# the datasource. Changes to it will not persist across an instance.
122# To disable cloud-init's network configuration capabilities, write a file
123# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
124# network: {config: disabled}
125network:
126 version: 2
127 ethernets:
128 eth0:
129 addresses:
130 - 192.168.1.5/255.255.255.0
131 gateway4: 192.168.1.254
132 eth1:
133 dhcp4: true
134"""
135
136V2_NET_CFG = {
137 'ethernets': {
138 'eth7': {
139 'addresses': ['192.168.1.5/255.255.255.0'],
140 'gateway4': '192.168.1.254'},
141 'eth9': {
142 'dhcp4': True}
143 },
144 'version': 2
145}
146
147
148V2_TO_V2_NET_CFG_OUTPUT = """
149# This file is generated from information provided by
150# the datasource. Changes to it will not persist across an instance.
151# To disable cloud-init's network configuration capabilities, write a file
152# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
153# network: {config: disabled}
154network:
155 version: 2
156 ethernets:
157 eth7:
158 addresses:
159 - 192.168.1.5/255.255.255.0
160 gateway4: 192.168.1.254
161 eth9:
162 dhcp4: true
163"""
164
70165
71class WriteBuffer(object):166class WriteBuffer(object):
72 def __init__(self):167 def __init__(self):
@@ -83,12 +178,14 @@ class WriteBuffer(object):
83178
84class TestNetCfgDistro(TestCase):179class TestNetCfgDistro(TestCase):
85180
86 def _get_distro(self, dname):181 def _get_distro(self, dname, renderers=None):
87 cls = distros.fetch(dname)182 cls = distros.fetch(dname)
88 cfg = settings.CFG_BUILTIN183 cfg = settings.CFG_BUILTIN
89 cfg['system_info']['distro'] = dname184 cfg['system_info']['distro'] = dname
185 if renderers:
186 cfg['system_info']['network'] = {'renderers': renderers}
90 paths = helpers.Paths({})187 paths = helpers.Paths({})
91 return cls(dname, cfg, paths)188 return cls(dname, cfg.get('system_info'), paths)
92189
93 def test_simple_write_ub(self):190 def test_simple_write_ub(self):
94 ub_distro = self._get_distro('ubuntu')191 ub_distro = self._get_distro('ubuntu')
@@ -116,6 +213,107 @@ class TestNetCfgDistro(TestCase):
116 self.assertEqual(str(write_buf).strip(), BASE_NET_CFG.strip())213 self.assertEqual(str(write_buf).strip(), BASE_NET_CFG.strip())
117 self.assertEqual(write_buf.mode, 0o644)214 self.assertEqual(write_buf.mode, 0o644)
118215
216 def test_apply_network_config_eni_ub(self):
217 ub_distro = self._get_distro('ubuntu')
218 with ExitStack() as mocks:
219 write_bufs = {}
220
221 def replace_write(filename, content, mode=0o644, omode="wb"):
222 buf = WriteBuffer()
223 buf.mode = mode
224 buf.omode = omode
225 buf.write(content)
226 write_bufs[filename] = buf
227
228 # eni availability checks
229 mocks.enter_context(
230 mock.patch.object(util, 'which', return_value=True))
231 mocks.enter_context(
232 mock.patch.object(eni, 'available', return_value=True))
233 mocks.enter_context(
234 mock.patch.object(util, 'ensure_dir'))
235 mocks.enter_context(
236 mock.patch.object(util, 'write_file', replace_write))
237 mocks.enter_context(
238 mock.patch.object(os.path, 'isfile', return_value=False))
239
240 ub_distro.apply_network_config(V1_NET_CFG, False)
241
242 self.assertEqual(len(write_bufs), 2)
243 eni_name = '/etc/network/interfaces.d/50-cloud-init.cfg'
244 self.assertIn(eni_name, write_bufs)
245 write_buf = write_bufs[eni_name]
246 self.assertEqual(str(write_buf).strip(), V1_NET_CFG_OUTPUT.strip())
247 self.assertEqual(write_buf.mode, 0o644)
248
249 def test_apply_network_config_v1_to_netplan_ub(self):
250 renderers = ['netplan']
251 ub_distro = self._get_distro('ubuntu', renderers=renderers)
252 with ExitStack() as mocks:
253 write_bufs = {}
254
255 def replace_write(filename, content, mode=0o644, omode="wb"):
256 buf = WriteBuffer()
257 buf.mode = mode
258 buf.omode = omode
259 buf.write(content)
260 write_bufs[filename] = buf
261
262 mocks.enter_context(
263 mock.patch.object(util, 'which', return_value=True))
264 mocks.enter_context(
265 mock.patch.object(util, 'write_file', replace_write))
266 mocks.enter_context(
267 mock.patch.object(util, 'ensure_dir'))
268 mocks.enter_context(
269 mock.patch.object(util, 'subp', return_value=(0, 0)))
270 mocks.enter_context(
271 mock.patch.object(os.path, 'isfile', return_value=False))
272
273 ub_distro.apply_network_config(V1_NET_CFG, False)
274
275 self.assertEqual(len(write_bufs), 1)
276 netplan_name = '/etc/netplan/50-cloud-init.yaml'
277 self.assertIn(netplan_name, write_bufs)
278 write_buf = write_bufs[netplan_name]
279 self.assertEqual(str(write_buf).strip(),
280 V1_TO_V2_NET_CFG_OUTPUT.strip())
281 self.assertEqual(write_buf.mode, 0o644)
282
283 def test_apply_network_config_v2_passthrough_ub(self):
284 renderers = ['netplan']
285 ub_distro = self._get_distro('ubuntu', renderers=renderers)
286 with ExitStack() as mocks:
287 write_bufs = {}
288
289 def replace_write(filename, content, mode=0o644, omode="wb"):
290 buf = WriteBuffer()
291 buf.mode = mode
292 buf.omode = omode
293 buf.write(content)
294 write_bufs[filename] = buf
295
296 mocks.enter_context(
297 mock.patch.object(util, 'which', return_value=True))
298 mocks.enter_context(
299 mock.patch.object(util, 'write_file', replace_write))
300 mocks.enter_context(
301 mock.patch.object(util, 'ensure_dir'))
302 mocks.enter_context(
303 mock.patch.object(util, 'subp', return_value=(0, 0)))
304 mocks.enter_context(
305 mock.patch.object(os.path, 'isfile', return_value=False))
306
307 ub_distro.apply_network_config(V2_NET_CFG, False)
308
309 self.assertEqual(len(write_bufs), 1)
310 netplan_name = '/etc/netplan/50-cloud-init.yaml'
311 self.assertIn(netplan_name, write_bufs)
312 write_buf = write_bufs[netplan_name]
313 self.assertEqual(str(write_buf).strip(),
314 V2_TO_V2_NET_CFG_OUTPUT.strip())
315 self.assertEqual(write_buf.mode, 0o644)
316
119 def assertCfgEquals(self, blob1, blob2):317 def assertCfgEquals(self, blob1, blob2):
120 b1 = dict(SysConf(blob1.strip().splitlines()))318 b1 = dict(SysConf(blob1.strip().splitlines()))
121 b2 = dict(SysConf(blob2.strip().splitlines()))319 b2 = dict(SysConf(blob2.strip().splitlines()))
@@ -195,6 +393,79 @@ NETWORKING=yes
195 self.assertCfgEquals(expected_buf, str(write_buf))393 self.assertCfgEquals(expected_buf, str(write_buf))
196 self.assertEqual(write_buf.mode, 0o644)394 self.assertEqual(write_buf.mode, 0o644)
197395
396 def test_apply_network_config_rh(self):
397 renderers = ['sysconfig']
398 rh_distro = self._get_distro('rhel', renderers=renderers)
399
400 write_bufs = {}
401
402 def replace_write(filename, content, mode=0o644, omode="wb"):
403 buf = WriteBuffer()
404 buf.mode = mode
405 buf.omode = omode
406 buf.write(content)
407 write_bufs[filename] = buf
408
409 with ExitStack() as mocks:
410 # sysconfig availability checks
411 mocks.enter_context(
412 mock.patch.object(util, 'which', return_value=True))
413 mocks.enter_context(
414 mock.patch.object(util, 'write_file', replace_write))
415 mocks.enter_context(
416 mock.patch.object(util, 'load_file', return_value=''))
417 mocks.enter_context(
418 mock.patch.object(os.path, 'isfile', return_value=True))
419
420 rh_distro.apply_network_config(V1_NET_CFG, False)
421
422 self.assertEqual(len(write_bufs), 5)
423
424 # eth0
425 self.assertIn('/etc/sysconfig/network-scripts/ifcfg-eth0',
426 write_bufs)
427 write_buf = write_bufs['/etc/sysconfig/network-scripts/ifcfg-eth0']
428 expected_buf = '''
429# Created by cloud-init on instance boot automatically, do not edit.
430#
431BOOTPROTO=static
432DEVICE=eth0
433IPADDR=192.168.1.5
434NETMASK=255.255.255.0
435NM_CONTROLLED=no
436ONBOOT=yes
437TYPE=Ethernet
438USERCTL=no
439'''
440 self.assertCfgEquals(expected_buf, str(write_buf))
441 self.assertEqual(write_buf.mode, 0o644)
442
443 # eth1
444 self.assertIn('/etc/sysconfig/network-scripts/ifcfg-eth1',
445 write_bufs)
446 write_buf = write_bufs['/etc/sysconfig/network-scripts/ifcfg-eth1']
447 expected_buf = '''
448# Created by cloud-init on instance boot automatically, do not edit.
449#
450BOOTPROTO=dhcp
451DEVICE=eth1
452NM_CONTROLLED=no
453ONBOOT=yes
454TYPE=Ethernet
455USERCTL=no
456'''
457 self.assertCfgEquals(expected_buf, str(write_buf))
458 self.assertEqual(write_buf.mode, 0o644)
459
460 self.assertIn('/etc/sysconfig/network', write_bufs)
461 write_buf = write_bufs['/etc/sysconfig/network']
462 expected_buf = '''
463# Created by cloud-init v. 0.7
464NETWORKING=yes
465'''
466 self.assertCfgEquals(expected_buf, str(write_buf))
467 self.assertEqual(write_buf.mode, 0o644)
468
198 def test_write_ipv6_rhel(self):469 def test_write_ipv6_rhel(self):
199 rh_distro = self._get_distro('rhel')470 rh_distro = self._get_distro('rhel')
200471
@@ -274,6 +545,78 @@ IPV6_AUTOCONF=no
274 self.assertCfgEquals(expected_buf, str(write_buf))545 self.assertCfgEquals(expected_buf, str(write_buf))
275 self.assertEqual(write_buf.mode, 0o644)546 self.assertEqual(write_buf.mode, 0o644)
276547
548 def test_apply_network_config_ipv6_rh(self):
549 renderers = ['sysconfig']
550 rh_distro = self._get_distro('rhel', renderers=renderers)
551
552 write_bufs = {}
553
554 def replace_write(filename, content, mode=0o644, omode="wb"):
555 buf = WriteBuffer()
556 buf.mode = mode
557 buf.omode = omode
558 buf.write(content)
559 write_bufs[filename] = buf
560
561 with ExitStack() as mocks:
562 mocks.enter_context(
563 mock.patch.object(util, 'which', return_value=True))
564 mocks.enter_context(
565 mock.patch.object(util, 'write_file', replace_write))
566 mocks.enter_context(
567 mock.patch.object(util, 'load_file', return_value=''))
568 mocks.enter_context(
569 mock.patch.object(os.path, 'isfile', return_value=True))
570
571 rh_distro.apply_network_config(V1_NET_CFG_IPV6, False)
572
573 self.assertEqual(len(write_bufs), 5)
574
575 self.assertIn('/etc/sysconfig/network-scripts/ifcfg-eth0',
576 write_bufs)
577 write_buf = write_bufs['/etc/sysconfig/network-scripts/ifcfg-eth0']
578 expected_buf = '''
579# Created by cloud-init on instance boot automatically, do not edit.
580#
581BOOTPROTO=static
582DEVICE=eth0
583IPV6ADDR=2607:f0d0:1002:0011::2
584IPV6INIT=yes
585NETMASK=64
586NM_CONTROLLED=no
587ONBOOT=yes
588TYPE=Ethernet
589USERCTL=no
590'''
591 self.assertCfgEquals(expected_buf, str(write_buf))
592 self.assertEqual(write_buf.mode, 0o644)
593 self.assertIn('/etc/sysconfig/network-scripts/ifcfg-eth1',
594 write_bufs)
595 write_buf = write_bufs['/etc/sysconfig/network-scripts/ifcfg-eth1']
596 expected_buf = '''
597# Created by cloud-init on instance boot automatically, do not edit.
598#
599BOOTPROTO=dhcp
600DEVICE=eth1
601NM_CONTROLLED=no
602ONBOOT=yes
603TYPE=Ethernet
604USERCTL=no
605'''
606 self.assertCfgEquals(expected_buf, str(write_buf))
607 self.assertEqual(write_buf.mode, 0o644)
608
609 self.assertIn('/etc/sysconfig/network', write_bufs)
610 write_buf = write_bufs['/etc/sysconfig/network']
611 expected_buf = '''
612# Created by cloud-init v. 0.7
613NETWORKING=yes
614NETWORKING_IPV6=yes
615IPV6_AUTOCONF=no
616'''
617 self.assertCfgEquals(expected_buf, str(write_buf))
618 self.assertEqual(write_buf.mode, 0o644)
619
277 def test_simple_write_freebsd(self):620 def test_simple_write_freebsd(self):
278 fbsd_distro = self._get_distro('freebsd')621 fbsd_distro = self._get_distro('freebsd')
279622
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
index 902204a..4f07d80 100644
--- a/tests/unittests/test_net.py
+++ b/tests/unittests/test_net.py
@@ -3,6 +3,7 @@
3from cloudinit import net3from cloudinit import net
4from cloudinit.net import cmdline4from cloudinit.net import cmdline
5from cloudinit.net import eni5from cloudinit.net import eni
6from cloudinit.net import netplan
6from cloudinit.net import network_state7from cloudinit.net import network_state
7from cloudinit.net import renderers8from cloudinit.net import renderers
8from cloudinit.net import sysconfig9from cloudinit.net import sysconfig
@@ -408,6 +409,41 @@ NETWORK_CONFIGS = {
408 post-up route add default gw 65.61.151.37 || true409 post-up route add default gw 65.61.151.37 || true
409 pre-down route del default gw 65.61.151.37 || true410 pre-down route del default gw 65.61.151.37 || true
410 """).rstrip(' '),411 """).rstrip(' '),
412 'expected_netplan': textwrap.dedent("""
413 network:
414 version: 2
415 ethernets:
416 eth1:
417 match:
418 macaddress: cf:d6:af:48:e8:80
419 nameservers:
420 addresses:
421 - 1.2.3.4
422 - 5.6.7.8
423 search:
424 - wark.maas
425 set-name: eth1
426 eth99:
427 addresses:
428 - 192.168.21.3/24
429 dhcp4: true
430 match:
431 macaddress: c0:d6:9f:2c:e8:80
432 nameservers:
433 addresses:
434 - 8.8.8.8
435 - 8.8.4.4
436 - 1.2.3.4
437 - 5.6.7.8
438 search:
439 - barley.maas
440 - sach.maas
441 - wark.maas
442 routes:
443 - to: 0.0.0.0/0.0.0.0
444 via: 65.61.151.37
445 set-name: eth99
446 """).rstrip(' '),
411 'yaml': textwrap.dedent("""447 'yaml': textwrap.dedent("""
412 version: 1448 version: 1
413 config:449 config:
@@ -450,6 +486,14 @@ NETWORK_CONFIGS = {
450 # control-alias iface0486 # control-alias iface0
451 iface iface0 inet6 dhcp487 iface iface0 inet6 dhcp
452 """).rstrip(' '),488 """).rstrip(' '),
489 'expected_netplan': textwrap.dedent("""
490 network:
491 version: 2
492 ethernets:
493 iface0:
494 dhcp4: true
495 dhcp6: true
496 """).rstrip(' '),
453 'yaml': textwrap.dedent("""\497 'yaml': textwrap.dedent("""\
454 version: 1498 version: 1
455 config:499 config:
@@ -524,6 +568,126 @@ iface eth0.101 inet static
524post-up route add -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true568post-up route add -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true
525pre-down route del -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true569pre-down route del -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true
526"""),570"""),
571 'expected_netplan': textwrap.dedent("""
572 network:
573 version: 2
574 ethernets:
575 eth0:
576 match:
577 macaddress: c0:d6:9f:2c:e8:80
578 nameservers:
579 addresses:
580 - 8.8.8.8
581 - 4.4.4.4
582 - 8.8.4.4
583 search:
584 - barley.maas
585 - wark.maas
586 - foobar.maas
587 set-name: eth0
588 eth1:
589 match:
590 macaddress: aa:d6:9f:2c:e8:80
591 nameservers:
592 addresses:
593 - 8.8.8.8
594 - 4.4.4.4
595 - 8.8.4.4
596 search:
597 - barley.maas
598 - wark.maas
599 - foobar.maas
600 set-name: eth1
601 eth2:
602 match:
603 macaddress: c0:bb:9f:2c:e8:80
604 nameservers:
605 addresses:
606 - 8.8.8.8
607 - 4.4.4.4
608 - 8.8.4.4
609 search:
610 - barley.maas
611 - wark.maas
612 - foobar.maas
613 set-name: eth2
614 eth3:
615 match:
616 macaddress: 66:bb:9f:2c:e8:80
617 nameservers:
618 addresses:
619 - 8.8.8.8
620 - 4.4.4.4
621 - 8.8.4.4
622 search:
623 - barley.maas
624 - wark.maas
625 - foobar.maas
626 set-name: eth3
627 eth4:
628 match:
629 macaddress: 98:bb:9f:2c:e8:80
630 nameservers:
631 addresses:
632 - 8.8.8.8
633 - 4.4.4.4
634 - 8.8.4.4
635 search:
636 - barley.maas
637 - wark.maas
638 - foobar.maas
639 set-name: eth4
640 eth5:
641 dhcp4: true
642 match:
643 macaddress: 98:bb:9f:2c:e8:8a
644 nameservers:
645 addresses:
646 - 8.8.8.8
647 - 4.4.4.4
648 - 8.8.4.4
649 search:
650 - barley.maas
651 - wark.maas
652 - foobar.maas
653 set-name: eth5
654 bonds:
655 bond0:
656 dhcp6: true
657 interfaces:
658 - eth1
659 - eth2
660 parameters:
661 mode: active-backup
662 bridges:
663 br0:
664 addresses:
665 - 192.168.14.2/24
666 - 2001:1::1/64
667 interfaces:
668 - eth3
669 - eth4
670 vlans:
671 bond0.200:
672 dhcp4: true
673 id: 200
674 link: bond0
675 eth0.101:
676 addresses:
677 - 192.168.0.2/24
678 - 192.168.2.10/24
679 gateway4: 192.168.0.1
680 id: 101
681 link: eth0
682 nameservers:
683 addresses:
684 - 192.168.0.10
685 - 10.23.23.134
686 search:
687 - barley.maas
688 - sacchromyces.maas
689 - brettanomyces.maas
690 """).rstrip(' '),
527 'yaml': textwrap.dedent("""691 'yaml': textwrap.dedent("""
528 version: 1692 version: 1
529 config:693 config:
@@ -808,6 +972,99 @@ iface eth0 inet dhcp
808 expected, dir2dict(tmp_dir)['/etc/network/interfaces'])972 expected, dir2dict(tmp_dir)['/etc/network/interfaces'])
809973
810974
975class TestNetplanNetRendering(CiTestCase):
976
977 @mock.patch("cloudinit.net.sys_dev_path")
978 @mock.patch("cloudinit.net.read_sys_net")
979 @mock.patch("cloudinit.net.get_devicelist")
980 def test_default_generation(self, mock_get_devicelist,
981 mock_read_sys_net,
982 mock_sys_dev_path):
983 tmp_dir = self.tmp_dir()
984 _setup_test(tmp_dir, mock_get_devicelist,
985 mock_read_sys_net, mock_sys_dev_path)
986
987 network_cfg = net.generate_fallback_config()
988 ns = network_state.parse_net_config_data(network_cfg,
989 skip_broken=False)
990
991 render_dir = os.path.join(tmp_dir, "render")
992 os.makedirs(render_dir)
993
994 render_target = 'netplan.yaml'
995 renderer = netplan.Renderer(
996 {'netplan_path': render_target, 'postcmds': False})
997 renderer.render_network_state(render_dir, ns)
998
999 self.assertTrue(os.path.exists(os.path.join(render_dir,
1000 render_target)))
1001 with open(os.path.join(render_dir, render_target)) as fh:
1002 contents = fh.read()
1003 print(contents)
1004
1005 expected = """
1006network:
1007 version: 2
1008 ethernets:
1009 eth1000:
1010 dhcp4: true
1011 match:
1012 macaddress: 07-1c-c6-75-a4-be
1013 set-name: eth1000
1014"""
1015 self.assertEqual(expected.lstrip(), contents.lstrip())
1016
1017
1018class TestNetplanPostcommands(CiTestCase):
1019 mycfg = {
1020 'config': [{"type": "physical", "name": "eth0",
1021 "mac_address": "c0:d6:9f:2c:e8:80",
1022 "subnets": [{"type": "dhcp"}]}],
1023 'version': 1}
1024
1025 @mock.patch.object(netplan.Renderer, '_netplan_generate')
1026 @mock.patch.object(netplan.Renderer, '_net_setup_link')
1027 def test_netplan_render_calls_postcmds(self, mock_netplan_generate,
1028 mock_net_setup_link):
1029 tmp_dir = self.tmp_dir()
1030 ns = network_state.parse_net_config_data(self.mycfg,
1031 skip_broken=False)
1032
1033 render_dir = os.path.join(tmp_dir, "render")
1034 os.makedirs(render_dir)
1035
1036 render_target = 'netplan.yaml'
1037 renderer = netplan.Renderer(
1038 {'netplan_path': render_target, 'postcmds': True})
1039 renderer.render_network_state(render_dir, ns)
1040
1041 mock_netplan_generate.assert_called_with(run=True)
1042 mock_net_setup_link.assert_called_with(run=True)
1043
1044 @mock.patch.object(netplan, "get_devicelist")
1045 @mock.patch('cloudinit.util.subp')
1046 def test_netplan_postcmds(self, mock_subp, mock_devlist):
1047 mock_devlist.side_effect = [['lo']]
1048 tmp_dir = self.tmp_dir()
1049 ns = network_state.parse_net_config_data(self.mycfg,
1050 skip_broken=False)
1051
1052 render_dir = os.path.join(tmp_dir, "render")
1053 os.makedirs(render_dir)
1054
1055 render_target = 'netplan.yaml'
1056 renderer = netplan.Renderer(
1057 {'netplan_path': render_target, 'postcmds': True})
1058 renderer.render_network_state(render_dir, ns)
1059
1060 expected = [
1061 mock.call(['netplan', 'generate'], capture=True),
1062 mock.call(['udevadm', 'test-builtin', 'net_setup_link',
1063 '/sys/class/net/lo'], capture=True),
1064 ]
1065 mock_subp.assert_has_calls(expected)
1066
1067
811class TestEniNetworkStateToEni(CiTestCase):1068class TestEniNetworkStateToEni(CiTestCase):
812 mycfg = {1069 mycfg = {
813 'config': [{"type": "physical", "name": "eth0",1070 'config': [{"type": "physical", "name": "eth0",
@@ -953,6 +1210,50 @@ class TestCmdlineReadKernelConfig(CiTestCase):
953 self.assertEqual(found['config'], expected)1210 self.assertEqual(found['config'], expected)
9541211
9551212
1213class TestNetplanRoundTrip(CiTestCase):
1214 def _render_and_read(self, network_config=None, state=None,
1215 netplan_path=None, dir=None):
1216 if dir is None:
1217 dir = self.tmp_dir()
1218
1219 if network_config:
1220 ns = network_state.parse_net_config_data(network_config)
1221 elif state:
1222 ns = state
1223 else:
1224 raise ValueError("Expected data or state, got neither")
1225
1226 if netplan_path is None:
1227 netplan_path = 'etc/netplan/50-cloud-init.yaml'
1228
1229 renderer = netplan.Renderer(
1230 config={'netplan_path': netplan_path})
1231
1232 renderer.render_network_state(dir, ns)
1233 return dir2dict(dir)
1234
1235 def testsimple_render_small_netplan(self):
1236 entry = NETWORK_CONFIGS['small']
1237 files = self._render_and_read(network_config=yaml.load(entry['yaml']))
1238 self.assertEqual(
1239 entry['expected_netplan'].splitlines(),
1240 files['/etc/netplan/50-cloud-init.yaml'].splitlines())
1241
1242 def testsimple_render_v4_and_v6(self):
1243 entry = NETWORK_CONFIGS['v4_and_v6']
1244 files = self._render_and_read(network_config=yaml.load(entry['yaml']))
1245 self.assertEqual(
1246 entry['expected_netplan'].splitlines(),
1247 files['/etc/netplan/50-cloud-init.yaml'].splitlines())
1248
1249 def testsimple_render_all(self):
1250 entry = NETWORK_CONFIGS['all']
1251 files = self._render_and_read(network_config=yaml.load(entry['yaml']))
1252 self.assertEqual(
1253 entry['expected_netplan'].splitlines(),
1254 files['/etc/netplan/50-cloud-init.yaml'].splitlines())
1255
1256
956class TestEniRoundTrip(CiTestCase):1257class TestEniRoundTrip(CiTestCase):
957 def _render_and_read(self, network_config=None, state=None, eni_path=None,1258 def _render_and_read(self, network_config=None, state=None, eni_path=None,
958 links_prefix=None, netrules_path=None, dir=None):1259 links_prefix=None, netrules_path=None, dir=None):
diff --git a/tools/net-convert.py b/tools/net-convert.py
959new file mode 1007551260new file mode 100755
index 0000000..1424bb0
--- /dev/null
+++ b/tools/net-convert.py
@@ -0,0 +1,83 @@
1#!/usr/bin/python3
2#
3# This file is part of cloud-init. See LICENSE file ...
4
5import argparse
6import json
7import os
8import yaml
9
10from cloudinit.sources.helpers import openstack
11
12from cloudinit.net import eni
13from cloudinit.net import network_state
14from cloudinit.net import netplan
15from cloudinit.net import sysconfig
16
17
18def main():
19 parser = argparse.ArgumentParser()
20 parser.add_argument("--network-data", "-p", type=open,
21 metavar="PATH", required=True)
22 parser.add_argument("--kind", "-k",
23 choices=['eni', 'network_data.json', 'yaml'],
24 required=True)
25 parser.add_argument("-d", "--directory",
26 metavar="PATH",
27 help="directory to place output in",
28 required=True)
29 parser.add_argument("-m", "--mac",
30 metavar="name,mac",
31 action='append',
32 help="interface name to mac mapping")
33 parser.add_argument("--output-kind", "-ok",
34 choices=['eni', 'netplan', 'sysconfig'],
35 required=True)
36 args = parser.parse_args()
37
38 if not os.path.isdir(args.directory):
39 os.makedirs(args.directory)
40
41 if args.mac:
42 known_macs = {}
43 for item in args.mac:
44 iface_name, iface_mac = item.split(",", 1)
45 known_macs[iface_mac] = iface_name
46 else:
47 known_macs = None
48
49 net_data = args.network_data.read()
50 if args.kind == "eni":
51 pre_ns = eni.convert_eni_data(net_data)
52 ns = network_state.parse_net_config_data(pre_ns)
53 elif args.kind == "yaml":
54 pre_ns = yaml.load(net_data)
55 if 'network' in pre_ns:
56 pre_ns = pre_ns.get('network')
57 print("Input YAML")
58 print(yaml.dump(pre_ns, default_flow_style=False, indent=4))
59 ns = network_state.parse_net_config_data(pre_ns)
60 else:
61 pre_ns = openstack.convert_net_json(
62 json.loads(net_data), known_macs=known_macs)
63 ns = network_state.parse_net_config_data(pre_ns)
64
65 if not ns:
66 raise RuntimeError("No valid network_state object created from"
67 "input data")
68
69 print("\nInternal State")
70 print(yaml.dump(ns, default_flow_style=False, indent=4))
71 if args.output_kind == "eni":
72 r_cls = eni.Renderer
73 elif args.output_kind == "netplan":
74 r_cls = netplan.Renderer
75 else:
76 r_cls = sysconfig.Renderer
77
78 r = r_cls()
79 r.render_network_state(ns, target=args.directory)
80
81
82if __name__ == '__main__':
83 main()

Subscribers

People subscribed via source and target branches