Merge ~smoser/cloud-init:feature/net-render-priority into cloud-init:master

Proposed by Scott Moser on 2017-03-17
Status: Merged
Merged at revision: 5beecdf88b630a397b3722ddb299e9a37ff02737
Proposed branch: ~smoser/cloud-init:feature/net-render-priority
Merge into: cloud-init:master
Diff against target: 422 lines (+196/-34)
12 files modified
cloudinit/distros/__init__.py (+13/-0)
cloudinit/distros/debian.py (+10/-13)
cloudinit/distros/rhel.py (+1/-6)
cloudinit/net/__init__.py (+5/-0)
cloudinit/net/eni.py (+14/-0)
cloudinit/net/renderer.py (+5/-0)
cloudinit/net/renderers.py (+51/-0)
cloudinit/net/sysconfig.py (+17/-0)
cloudinit/settings.py (+1/-0)
cloudinit/stages.py (+5/-1)
cloudinit/util.py (+29/-14)
tests/unittests/test_net.py (+45/-0)
Reviewer Review Type Date Requested Status
Scott Moser Approve on 2017-03-18
Server Team CI bot continuous-integration Approve on 2017-03-18
Ryan Harper 2017-03-17 Approve on 2017-03-17
Review via email: mp+320233@code.launchpad.net

Commit Message

net: add renderers for automatically selecting the renderer.

Previously, the distro had hard coded which network renderer it would
use. This adds support for just picking the right renderer based
on what is available.

Now, that can be set via a priority in system_info, but should
generally work. That config looks like:
 system_info:
   network:
     renderers: ["eni", "sysconfig"]

When no renderers are found, a specific RendererNotFoundError is raised.
stages.py is modified to catch that and log it at error level. This
path should not really be exercised, but could occur if for example an
Ubuntu system did not have ifupdown, or a rhel system did not have
sysconfig. In such a system previously we would have quietly rendered
ENI configuration but that would have been ignored. This is one step
better in that we at least log the error.

To post a comment you must log in.
Ryan Harper (raharper) :
review: Needs Fixing
Scott Moser (smoser) :
Scott Moser (smoser) wrote :

please re-review.

review: Resubmit
Ryan Harper (raharper) wrote :

Add no renderer found exception and unittest as well.

review: Needs Fixing
Scott Moser (smoser) wrote :

i think i've convinced you of /etc/network/interfaces versus /etc/network/interfaces.d
we both agree that currently our detection of "which networking system is used" is remedial, and will need improvement over time.

the tests you asked for are added now i think.

review: Resubmit
Ryan Harper (raharper) wrote :

That looks right; thanks.

review: Approve
Scott Moser (smoser) wrote :

The change to raise a RuntimeError if there is no network renderer I think can cause a problem.
I'm trying to think through this, but in a situation where there is no ifupdown or sysconfig, previously we'd just write some files that were not going to do anything.

Ie, on ubuntu if you installed systemd-networkd and configured it to do something, and uninstalled ifupdown, then cloud-init would run and write ENI config, but if the system was configured to do the right thing ("dhcp on eth0" or whatever that was) then it might continue to work.

However, raising a RuntimeError here will throw exception up all the way through main.

currently cloudinit/stages.py's "apply_network_config" does handle a NotImplementedError in apply_network_config.

So that leaves me thinking of 2 options:
a.) change the raised RuntimeError to a NotImplementedError (which seems sane)
b.) change cloudinit/stages.py:apply_network_config to similarly catch the RunTimeException.

I think for now, 'a' seems the best case because a RuntimeError seems like there couldb e more reasons for that to occur.

I'll also change the LOG.warn there to log what message it was (currently it didn't even print the exception's message.

Scott Moser (smoser) wrote :

in #cloud-init (https://irclogs.ubuntu.com/2017/03/17/%23cloud-init.html) rharper and I discussed and he was OK with what was here.

I've re-written the commit message to describe all changes made and squashed to trunk.
I'm pulling this now.

Scott Moser (smoser) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
2index f3d395b..803ac74 100755
3--- a/cloudinit/distros/__init__.py
4+++ b/cloudinit/distros/__init__.py
5@@ -22,6 +22,7 @@ from cloudinit import log as logging
6 from cloudinit import net
7 from cloudinit.net import eni
8 from cloudinit.net import network_state
9+from cloudinit.net import renderers
10 from cloudinit import ssh_util
11 from cloudinit import type_utils
12 from cloudinit import util
13@@ -50,6 +51,7 @@ class Distro(object):
14 hostname_conf_fn = "/etc/hostname"
15 tz_zone_dir = "/usr/share/zoneinfo"
16 init_cmd = ['service'] # systemctl, service etc
17+ renderer_configs = {}
18
19 def __init__(self, name, cfg, paths):
20 self._paths = paths
21@@ -69,6 +71,17 @@ class Distro(object):
22 def _write_network_config(self, settings):
23 raise NotImplementedError()
24
25+ def _supported_write_network_config(self, network_config):
26+ priority = util.get_cfg_by_path(
27+ self._cfg, ('network', 'renderers'), None)
28+
29+ name, render_cls = renderers.select(priority=priority)
30+ LOG.debug("Selected renderer '%s' from priority list: %s",
31+ name, priority)
32+ renderer = render_cls(config=self.renderer_configs.get(name))
33+ renderer.render_network_config(network_config=network_config)
34+ return []
35+
36 def _find_tz_file(self, tz):
37 tz_file = os.path.join(self.tz_zone_dir, str(tz))
38 if not os.path.isfile(tz_file):
39diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py
40index 48ccec8..1101f02 100644
41--- a/cloudinit/distros/debian.py
42+++ b/cloudinit/distros/debian.py
43@@ -13,8 +13,6 @@ import os
44 from cloudinit import distros
45 from cloudinit import helpers
46 from cloudinit import log as logging
47-from cloudinit.net import eni
48-from cloudinit.net.network_state import parse_net_config_data
49 from cloudinit import util
50
51 from cloudinit.distros.parsers.hostname import HostnameConf
52@@ -38,11 +36,18 @@ ENI_HEADER = """# This file is generated from information provided by
53 # network: {config: disabled}
54 """
55
56+NETWORK_CONF_FN = "/etc/network/interfaces.d/50-cloud-init.cfg"
57+
58
59 class Distro(distros.Distro):
60 hostname_conf_fn = "/etc/hostname"
61 locale_conf_fn = "/etc/default/locale"
62- network_conf_fn = "/etc/network/interfaces.d/50-cloud-init.cfg"
63+ renderer_configs = {
64+ 'eni': {
65+ 'eni_path': NETWORK_CONF_FN,
66+ 'eni_header': ENI_HEADER,
67+ }
68+ }
69
70 def __init__(self, name, cfg, paths):
71 distros.Distro.__init__(self, name, cfg, paths)
72@@ -51,12 +56,6 @@ class Distro(distros.Distro):
73 # should only happen say once per instance...)
74 self._runner = helpers.Runners(paths)
75 self.osfamily = 'debian'
76- self._net_renderer = eni.Renderer({
77- 'eni_path': self.network_conf_fn,
78- 'eni_header': ENI_HEADER,
79- 'links_path_prefix': None,
80- 'netrules_path': None,
81- })
82
83 def apply_locale(self, locale, out_fn=None):
84 if not out_fn:
85@@ -76,14 +75,12 @@ class Distro(distros.Distro):
86 self.package_command('install', pkgs=pkglist)
87
88 def _write_network(self, settings):
89- util.write_file(self.network_conf_fn, settings)
90+ util.write_file(NETWORK_CONF_FN, settings)
91 return ['all']
92
93 def _write_network_config(self, netconfig):
94- ns = parse_net_config_data(netconfig)
95- self._net_renderer.render_network_state("/", ns)
96 _maybe_remove_legacy_eth0()
97- return []
98+ return self._supported_write_network_config(netconfig)
99
100 def _bring_up_interfaces(self, device_names):
101 use_all = False
102diff --git a/cloudinit/distros/rhel.py b/cloudinit/distros/rhel.py
103index 7498c63..372c7d0 100644
104--- a/cloudinit/distros/rhel.py
105+++ b/cloudinit/distros/rhel.py
106@@ -11,8 +11,6 @@
107 from cloudinit import distros
108 from cloudinit import helpers
109 from cloudinit import log as logging
110-from cloudinit.net.network_state import parse_net_config_data
111-from cloudinit.net import sysconfig
112 from cloudinit import util
113
114 from cloudinit.distros import net_util
115@@ -49,16 +47,13 @@ class Distro(distros.Distro):
116 # should only happen say once per instance...)
117 self._runner = helpers.Runners(paths)
118 self.osfamily = 'redhat'
119- self._net_renderer = sysconfig.Renderer()
120 cfg['ssh_svcname'] = 'sshd'
121
122 def install_packages(self, pkglist):
123 self.package_command('install', pkgs=pkglist)
124
125 def _write_network_config(self, netconfig):
126- ns = parse_net_config_data(netconfig)
127- self._net_renderer.render_network_state("/", ns)
128- return []
129+ return self._supported_write_network_config(netconfig)
130
131 def _write_network(self, settings):
132 # TODO(harlowja) fix this... since this is the ubuntu format
133diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
134index ea649cc..1cf98ef 100755
135--- a/cloudinit/net/__init__.py
136+++ b/cloudinit/net/__init__.py
137@@ -364,4 +364,9 @@ def get_interfaces_by_mac(devs=None):
138 ret[mac] = name
139 return ret
140
141+
142+class RendererNotFoundError(RuntimeError):
143+ pass
144+
145+
146 # vi: ts=4 expandtab
147diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
148index efa034b..9d39a2b 100644
149--- a/cloudinit/net/eni.py
150+++ b/cloudinit/net/eni.py
151@@ -500,4 +500,18 @@ def network_state_to_eni(network_state, header=None, render_hwaddress=False):
152 network_state, render_hwaddress=render_hwaddress)
153 return header + contents
154
155+
156+def available(target=None):
157+ expected = ['ifquery', 'ifup', 'ifdown']
158+ search = ['/sbin', '/usr/sbin']
159+ for p in expected:
160+ if not util.which(p, search=search, target=target):
161+ return False
162+ eni = util.target_path(target, 'etc/network/interfaces')
163+ if not os.path.is_file(eni):
164+ return False
165+
166+ return True
167+
168+
169 # vi: ts=4 expandtab
170diff --git a/cloudinit/net/renderer.py b/cloudinit/net/renderer.py
171index 3a19243..a5b2b57 100644
172--- a/cloudinit/net/renderer.py
173+++ b/cloudinit/net/renderer.py
174@@ -7,6 +7,7 @@
175
176 import six
177
178+from .network_state import parse_net_config_data
179 from .udev import generate_udev_rule
180
181
182@@ -36,4 +37,8 @@ class Renderer(object):
183 iface['mac_address']))
184 return content.getvalue()
185
186+ def render_network_config(self, network_config, target=None):
187+ return self.render_network_state(
188+ network_state=parse_net_config_data(network_config), target=target)
189+
190 # vi: ts=4 expandtab
191diff --git a/cloudinit/net/renderers.py b/cloudinit/net/renderers.py
192new file mode 100644
193index 0000000..5ad8455
194--- /dev/null
195+++ b/cloudinit/net/renderers.py
196@@ -0,0 +1,51 @@
197+# This file is part of cloud-init. See LICENSE file for license information.
198+
199+from . import eni
200+from . import RendererNotFoundError
201+from . import sysconfig
202+
203+NAME_TO_RENDERER = {
204+ "eni": eni,
205+ "sysconfig": sysconfig,
206+}
207+
208+DEFAULT_PRIORITY = ["eni", "sysconfig"]
209+
210+
211+def search(priority=None, target=None, first=False):
212+ if priority is None:
213+ priority = DEFAULT_PRIORITY
214+
215+ available = NAME_TO_RENDERER
216+
217+ unknown = [i for i in priority if i not in available]
218+ if unknown:
219+ raise ValueError(
220+ "Unknown renderers provided in priority list: %s" % unknown)
221+
222+ found = []
223+ for name in priority:
224+ render_mod = available[name]
225+ if render_mod.available(target):
226+ cur = (name, render_mod.Renderer)
227+ if first:
228+ return cur
229+ found.append(cur)
230+
231+ return found
232+
233+
234+def select(priority=None, target=None):
235+ found = search(priority, target=target, first=True)
236+ if not found:
237+ if priority is None:
238+ priority = DEFAULT_PRIORITY
239+ tmsg = ""
240+ if target and target != "/":
241+ tmsg = " in target=%s" % target
242+ raise RendererNotFoundError(
243+ "No available network renderers found%s. Searched "
244+ "through list: %s" % (tmsg, priority))
245+ return found
246+
247+# vi: ts=4 expandtab
248diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
249index 4eeaaa8..117b515 100644
250--- a/cloudinit/net/sysconfig.py
251+++ b/cloudinit/net/sysconfig.py
252@@ -404,4 +404,21 @@ class Renderer(renderer.Renderer):
253 netrules_path = util.target_path(target, self.netrules_path)
254 util.write_file(netrules_path, netrules_content)
255
256+
257+def available(target=None):
258+ expected = ['ifup', 'ifdown']
259+ search = ['/sbin', '/usr/sbin']
260+ for p in expected:
261+ if not util.which(p, search=search, target=target):
262+ return False
263+
264+ expected_paths = [
265+ 'etc/sysconfig/network-scripts/network-functions',
266+ 'etc/sysconfig/network-scripts/ifdown-eth']
267+ for p in expected_paths:
268+ if not os.path.isfile(util.target_path(target, p)):
269+ return False
270+ return True
271+
272+
273 # vi: ts=4 expandtab
274diff --git a/cloudinit/settings.py b/cloudinit/settings.py
275index 692ff5e..dbafead 100644
276--- a/cloudinit/settings.py
277+++ b/cloudinit/settings.py
278@@ -46,6 +46,7 @@ CFG_BUILTIN = {
279 'templates_dir': '/etc/cloud/templates/',
280 },
281 'distro': 'ubuntu',
282+ 'network': {'renderers': None},
283 },
284 'vendor_data': {'enabled': True, 'prefix': []},
285 }
286diff --git a/cloudinit/stages.py b/cloudinit/stages.py
287index 5bed903..1216543 100644
288--- a/cloudinit/stages.py
289+++ b/cloudinit/stages.py
290@@ -646,9 +646,13 @@ class Init(object):
291 src, bring_up, netcfg)
292 try:
293 return self.distro.apply_network_config(netcfg, bring_up=bring_up)
294+ except net.RendererNotFoundError as e:
295+ LOG.error("Unable to render networking. Network config is "
296+ "likely broken: %s", e)
297+ return
298 except NotImplementedError:
299 LOG.warn("distro '%s' does not implement apply_network_config. "
300- "networking may not be configured properly." %
301+ "networking may not be configured properly.",
302 self.distro)
303 return
304
305diff --git a/cloudinit/util.py b/cloudinit/util.py
306index 7196a7c..82f2f76 100644
307--- a/cloudinit/util.py
308+++ b/cloudinit/util.py
309@@ -2099,21 +2099,36 @@ def get_mount_info(path, log=LOG):
310 return parse_mount(path)
311
312
313-def which(program):
314- # Return path of program for execution if found in path
315- def is_exe(fpath):
316- return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
317-
318- _fpath, _ = os.path.split(program)
319- if _fpath:
320- if is_exe(program):
321+def is_exe(fpath):
322+ # return boolean indicating if fpath exists and is executable.
323+ return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
324+
325+
326+def which(program, search=None, target=None):
327+ target = target_path(target)
328+
329+ if os.path.sep in program:
330+ # if program had a '/' in it, then do not search PATH
331+ # 'which' does consider cwd here. (cd / && which bin/ls) = bin/ls
332+ # so effectively we set cwd to / (or target)
333+ if is_exe(target_path(target, program)):
334 return program
335- else:
336- for path in os.environ.get("PATH", "").split(os.pathsep):
337- path = path.strip('"')
338- exe_file = os.path.join(path, program)
339- if is_exe(exe_file):
340- return exe_file
341+
342+ if search is None:
343+ paths = [p.strip('"') for p in
344+ os.environ.get("PATH", "").split(os.pathsep)]
345+ if target == "/":
346+ search = paths
347+ else:
348+ search = [p for p in paths if p.startswith("/")]
349+
350+ # normalize path input
351+ search = [os.path.abspath(p) for p in search]
352+
353+ for path in search:
354+ ppath = os.path.sep.join((path, program))
355+ if is_exe(target_path(target, ppath)):
356+ return ppath
357
358 return None
359
360diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
361index dca44b3..902204a 100644
362--- a/tests/unittests/test_net.py
363+++ b/tests/unittests/test_net.py
364@@ -4,6 +4,7 @@ from cloudinit import net
365 from cloudinit.net import cmdline
366 from cloudinit.net import eni
367 from cloudinit.net import network_state
368+from cloudinit.net import renderers
369 from cloudinit.net import sysconfig
370 from cloudinit.sources.helpers import openstack
371 from cloudinit import util
372@@ -1050,6 +1051,50 @@ class TestEniRoundTrip(CiTestCase):
373 expected, [line for line in found if line])
374
375
376+class TestNetRenderers(CiTestCase):
377+ @mock.patch("cloudinit.net.renderers.sysconfig.available")
378+ @mock.patch("cloudinit.net.renderers.eni.available")
379+ def test_eni_and_sysconfig_available(self, m_eni_avail, m_sysc_avail):
380+ m_eni_avail.return_value = True
381+ m_sysc_avail.return_value = True
382+ found = renderers.search(priority=['sysconfig', 'eni'], first=False)
383+ names = [f[0] for f in found]
384+ self.assertEqual(['sysconfig', 'eni'], names)
385+
386+ @mock.patch("cloudinit.net.renderers.eni.available")
387+ def test_search_returns_empty_on_none(self, m_eni_avail):
388+ m_eni_avail.return_value = False
389+ found = renderers.search(priority=['eni'], first=False)
390+ self.assertEqual([], found)
391+
392+ @mock.patch("cloudinit.net.renderers.sysconfig.available")
393+ @mock.patch("cloudinit.net.renderers.eni.available")
394+ def test_first_in_priority(self, m_eni_avail, m_sysc_avail):
395+ # available should only be called until one is found.
396+ m_eni_avail.return_value = True
397+ m_sysc_avail.side_effect = Exception("Should not call me")
398+ found = renderers.search(priority=['eni', 'sysconfig'], first=True)
399+ self.assertEqual(['eni'], [found[0]])
400+
401+ @mock.patch("cloudinit.net.renderers.sysconfig.available")
402+ @mock.patch("cloudinit.net.renderers.eni.available")
403+ def test_select_positive(self, m_eni_avail, m_sysc_avail):
404+ m_eni_avail.return_value = True
405+ m_sysc_avail.return_value = False
406+ found = renderers.select(priority=['sysconfig', 'eni'])
407+ self.assertEqual('eni', found[0])
408+
409+ @mock.patch("cloudinit.net.renderers.sysconfig.available")
410+ @mock.patch("cloudinit.net.renderers.eni.available")
411+ def test_select_none_found_raises(self, m_eni_avail, m_sysc_avail):
412+ # if select finds nothing, should raise exception.
413+ m_eni_avail.return_value = False
414+ m_sysc_avail.return_value = False
415+
416+ self.assertRaises(net.RendererNotFoundError, renderers.select,
417+ priority=['sysconfig', 'eni'])
418+
419+
420 def _gzip_data(data):
421 with io.BytesIO() as iobuf:
422 gzfp = gzip.GzipFile(mode="wb", fileobj=iobuf)

Subscribers

People subscribed via source and target branches

to all changes: