Merge lp:~jtv/maas/generate-node-networking-config-1 into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 3068
Proposed branch: lp:~jtv/maas/generate-node-networking-config-1
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 393 lines (+384/-0)
2 files modified
src/maasserver/networking_preseed.py (+121/-0)
src/maasserver/tests/test_networking_preseed.py (+263/-0)
To merge this branch: bzr merge lp:~jtv/maas/generate-node-networking-config-1
Reviewer Review Type Date Requested Status
Graham Binns (community) Approve
Review via email: mp+234413@code.launchpad.net

Commit message

First part: generate a curtin network configuration preseed along the lines of the example that Scott provided.

Description of the change

We'll be able to send this into Curtin (how exactly I'm hoping to hear from Scott tonight) and it will configure the node's networking for us, once implementation in cloud-init is complete. According to Scott this is the only right way to configure the networking, so it's worth the extra work.

Jeroen

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Some minor tweaks needed for clarity, but looks good! Thanks :)

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (23.9 KiB)

The attempt to merge lp:~jtv/maas/generate-node-networking-config-1 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Get:2 http://security.ubuntu.com trusty-security Release [59.7 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [59.7 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [44.9 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [10.8 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [144 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [48.5 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [120 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [85.2 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [322 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [205 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Fetched 1,101 kB in 0s (1,753 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make pep8 postgresql pyflakes python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mimeparse python-mock python-netaddr python-netifaces python-nose python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi py...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/networking_preseed.py'
2--- src/maasserver/networking_preseed.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/networking_preseed.py 2014-09-24 02:49:10 +0000
4@@ -0,0 +1,121 @@
5+# Copyright 2014 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+"""Network configuration preseed code.
9+
10+This will eventually generate installer networking configuration like:
11+
12+ https://gist.github.com/jayofdoom/b035067523defec7fb53
13+
14+The installer running on a node will use this to set up the node's networking
15+according to MAAS's specifications.
16+"""
17+
18+from __future__ import (
19+ absolute_import,
20+ print_function,
21+ unicode_literals,
22+ )
23+
24+str = None
25+
26+__metaclass__ = type
27+__all__ = [
28+ 'generate_networking_config',
29+ ]
30+
31+from lxml import etree
32+from maasserver.models.nodeprobeddetails import get_probed_details
33+
34+
35+def extract_network_interface_data(element):
36+ """Extract network interface name and MAC address from XML element.
37+
38+ :return: A tuple of network interface name and MAC address as found in
39+ the XML. If either is not found, it will be `None`.
40+ """
41+ interfaces = element.xpath("logicalname")
42+ macs = element.xpath("serial")
43+ if len(interfaces) == 0 or len(macs) == 0:
44+ # Not enough data.
45+ return None, None
46+ assert len(interfaces) == 1
47+ assert len(macs) == 1
48+ return interfaces[0].text, macs[0].text
49+
50+
51+def normalise_mac(mac):
52+ """Return a MAC's normalised representation.
53+
54+ This doesn't actually parse all the different formats for writing MAC
55+ addresses, but it does eliminate case differences. In practice, any MAC
56+ address this code is likely to will equal another version of itself after
57+ normalisation, even if they were originally different spellings.
58+ """
59+ return mac.strip().lower()
60+
61+
62+def extract_network_interfaces(node):
63+ """Extract network interfaces from node's `lshw` output.
64+
65+ :param node: A `Node`.
66+ :return: A list of tuples describing the network interfaces.
67+ Each tuple consists of an interface name and a MAC address.
68+ """
69+ node_details = get_probed_details([node.system_id])
70+ if node.system_id not in node_details:
71+ return []
72+ lshw_xml = node_details[node.system_id].get('lshw')
73+ if lshw_xml is None:
74+ return []
75+ network_nodes = etree.fromstring(lshw_xml).xpath("//node[@id='network']")
76+ interfaces = [
77+ extract_network_interface_data(xml_node)
78+ for xml_node in network_nodes
79+ ]
80+ return [
81+ (interface, normalise_mac(mac))
82+ for interface, mac in interfaces
83+ if interface is not None and mac is not None
84+ ]
85+
86+
87+def generate_ethernet_link_entry(interface, mac):
88+ """Generate the `links` list entry for the given ethernet interface.
89+
90+ :param interface: Network interface name, e.g. `eth0`.
91+ :param mac: MAC address, e.g. `00:11:22:33:44:55`.
92+ :return: A dict specifying the network interface, with keys
93+ `id`, `type`, and `ethernet_mac_address` (and possibly more).
94+ """
95+ return {
96+ 'id': interface,
97+ 'type': 'ethernet',
98+ 'ethernet_mac_address': mac,
99+ }
100+
101+
102+def generate_networking_config(node):
103+ """Generate a networking preseed for `node`.
104+
105+ :param node: A `Node`.
106+ :return: A dict along the lines of the example in
107+ https://gist.github.com/jayofdoom/b035067523defec7fb53 -- just
108+ json-encode it to get a file in that format.
109+ """
110+ interfaces = extract_network_interfaces(node)
111+ return {
112+ 'provider': "MAAS",
113+ 'network_info': {
114+ 'services': [
115+ # List DNS servers here.
116+ ],
117+ 'networks': [
118+ # Write network specs here.
119+ ],
120+ 'links': [
121+ generate_ethernet_link_entry(interface, mac)
122+ for interface, mac in interfaces
123+ ],
124+ },
125+ }
126
127=== added file 'src/maasserver/tests/test_networking_preseed.py'
128--- src/maasserver/tests/test_networking_preseed.py 1970-01-01 00:00:00 +0000
129+++ src/maasserver/tests/test_networking_preseed.py 2014-09-24 02:49:10 +0000
130@@ -0,0 +1,263 @@
131+# Copyright 2014 Canonical Ltd. This software is licensed under the
132+# GNU Affero General Public License version 3 (see the file LICENSE).
133+
134+"""Tests for networking preseed code."""
135+
136+from __future__ import (
137+ absolute_import,
138+ print_function,
139+ unicode_literals,
140+ )
141+
142+str = None
143+
144+__metaclass__ = type
145+__all__ = [
146+ ]
147+
148+from maasserver import networking_preseed
149+from maasserver.networking_preseed import (
150+ extract_network_interfaces,
151+ generate_ethernet_link_entry,
152+ generate_networking_config,
153+ normalise_mac,
154+ )
155+from maasserver.testing.factory import factory
156+from maasserver.testing.testcase import MAASServerTestCase
157+from maastesting.matchers import MockCalledOnceWith
158+
159+
160+def make_denormalised_mac():
161+ return ' %s ' % factory.make_mac_address().upper()
162+
163+
164+class TestExtractNetworkInterfaces(MAASServerTestCase):
165+
166+ def test__returns_nothing_if_no_lshw_output_found(self):
167+ node = factory.make_Node()
168+ self.assertEqual([], extract_network_interfaces(node))
169+
170+ def test__returns_nothing_if_no_network_description_found_in_lshw(self):
171+ node = factory.make_Node()
172+ lshw_output = """
173+ <list xmlns:lldp="lldp" xmlns:lshw="lshw">
174+ <lshw:list>
175+ </lshw:list>
176+ </list>
177+ """
178+ factory.make_NodeResult_for_commissioning(
179+ node=node, name='00-maas-01-lshw.out', script_result=0,
180+ data=lshw_output.encode('ascii'))
181+ self.assertEqual([], extract_network_interfaces(node))
182+
183+ def test__extracts_interface_data(self):
184+ node = factory.make_Node()
185+ interface = factory.make_name('eth')
186+ mac = factory.make_mac_address()
187+ lshw_output = """
188+ <node id="network" claimed="true" class="network">
189+ <logicalname>%(interface)s</logicalname>
190+ <serial>%(mac)s</serial>
191+ </node>
192+ """ % {'interface': interface, 'mac': mac}
193+ factory.make_NodeResult_for_commissioning(
194+ node=node, name='00-maas-01-lshw.out', script_result=0,
195+ data=lshw_output.encode('ascii'))
196+ self.assertEqual([(interface, mac)], extract_network_interfaces(node))
197+
198+ def test__finds_network_interface_on_motherboard(self):
199+ node = factory.make_Node()
200+ interface = factory.make_name('eth')
201+ mac = factory.make_mac_address()
202+ # Stripped-down version of real lshw output:
203+ lshw_output = """
204+ <!-- generated by lshw-B.02.16 -->
205+ <list>
206+ <node id="mynode" claimed="true" class="system" handle="DMI:0002">
207+ <node id="core" claimed="true" class="bus" handle="DMI:0003">
208+ <description>Motherboard</description>
209+ <node id="pci" claimed="true" class="bridge" \
210+ handle="PCIBUS:0000:00">
211+ <description>Host bridge</description>
212+ <node id="network" claimed="true" class="network" \
213+ handle="PCI:0000:00:19.0">
214+ <description>Ethernet interface</description>
215+ <product>82566DM-2 Gigabit Network Connection</product>
216+ <vendor>Intel Corporation</vendor>
217+ <logicalname>%(interface)s</logicalname>
218+ <serial>%(mac)s</serial>
219+ <configuration>
220+ <setting id="ip" value="10.99.99.1" />
221+ </configuration>
222+ </node>
223+ </node>
224+ </node>
225+ </node>
226+ </list>
227+ """ % {'interface': interface, 'mac': mac}
228+ factory.make_NodeResult_for_commissioning(
229+ node=node, name='00-maas-01-lshw.out', script_result=0,
230+ data=lshw_output.encode('ascii'))
231+ self.assertEqual([(interface, mac)], extract_network_interfaces(node))
232+
233+ def test__finds_network_interface_on_pci_bus(self):
234+ node = factory.make_Node()
235+ interface = factory.make_name('eth')
236+ mac = factory.make_mac_address()
237+ # Stripped-down version of real lshw output:
238+ lshw_output = """
239+ <!-- generated by lshw-B.02.16 -->
240+ <list>
241+ <node id="mynode" claimed="true" class="system" handle="DMI:0002">
242+ <node id="core" claimed="true" class="bus" handle="DMI:0003">
243+ <description>Motherboard</description>
244+ <node id="pci" claimed="true" class="bridge" \
245+ handle="PCIBUS:0000:00">
246+ <description>Host bridge</description>
247+ <node id="pci:2" claimed="true" class="bridge" \
248+ handle="PCIBUS:0000:07">
249+ <description>PCI bridge</description>
250+ <node id="network" claimed="true" class="network" \
251+ handle="PCI:0000:07:04.0">
252+ <description>Ethernet interface</description>
253+ <logicalname>%(interface)s</logicalname>
254+ <serial>%(mac)s</serial>
255+ <configuration>
256+ <setting id="ip" value="192.168.1.114" />
257+ </configuration>
258+ </node>
259+ </node>
260+ </node>
261+ </node>
262+ </node>
263+ </list>
264+ """ % {'interface': interface, 'mac': mac}
265+ factory.make_NodeResult_for_commissioning(
266+ node=node, name='00-maas-01-lshw.out', script_result=0,
267+ data=lshw_output.encode('ascii'))
268+ self.assertEqual([(interface, mac)], extract_network_interfaces(node))
269+
270+ def test__ignores_nodes_without_interface_name(self):
271+ node = factory.make_Node()
272+ mac = factory.make_mac_address()
273+ lshw_output = """
274+ <node id="network" claimed="true" class="network">
275+ <serial>%s</serial>
276+ </node>
277+ """ % mac
278+ factory.make_NodeResult_for_commissioning(
279+ node=node, name='00-maas-01-lshw.out', script_result=0,
280+ data=lshw_output.encode('ascii'))
281+ self.assertEqual([], extract_network_interfaces(node))
282+
283+ def test__ignores_nodes_without_mac(self):
284+ node = factory.make_Node()
285+ interface = factory.make_name('eth')
286+ lshw_output = """
287+ <node id="network" claimed="true" class="network">
288+ <logicalname>%s</logicalname>
289+ </node>
290+ """ % interface
291+ factory.make_NodeResult_for_commissioning(
292+ node=node, name='00-maas-01-lshw.out', script_result=0,
293+ data=lshw_output.encode('ascii'))
294+ self.assertEqual([], extract_network_interfaces(node))
295+
296+ def test__normalises_mac(self):
297+ node = factory.make_Node()
298+ interface = factory.make_name('eth')
299+ mac = make_denormalised_mac()
300+ self.assertNotEqual(normalise_mac(mac), mac)
301+ lshw_output = """
302+ <node id="network" claimed="true" class="network">
303+ <logicalname>%(interface)s</logicalname>
304+ <serial>%(mac)s</serial>
305+ </node>
306+ """ % {'interface': interface, 'mac': mac}
307+ factory.make_NodeResult_for_commissioning(
308+ node=node, name='00-maas-01-lshw.out', script_result=0,
309+ data=lshw_output.encode('ascii'))
310+ [entry] = extract_network_interfaces(node)
311+ _, extracted_mac = entry
312+ self.assertEqual(normalise_mac(mac), extracted_mac)
313+
314+
315+class TestNormaliseMAC(MAASServerTestCase):
316+
317+ def test__normalises_case(self):
318+ mac = factory.make_mac_address()
319+ self.assertEqual(
320+ normalise_mac(mac.lower()),
321+ normalise_mac(mac.upper()))
322+
323+ def test__strips_whitespace(self):
324+ mac = factory.make_mac_address()
325+ self.assertEqual(
326+ normalise_mac(mac),
327+ normalise_mac(' %s ' % mac))
328+
329+ def test__is_idempotent(self):
330+ mac = factory.make_mac_address()
331+ self.assertEqual(
332+ normalise_mac(mac),
333+ normalise_mac(normalise_mac(mac)))
334+
335+
336+class TestGenerateEthernetLinkEntry(MAASServerTestCase):
337+
338+ def test__generates_dict(self):
339+ interface = factory.make_name('eth')
340+ mac = factory.make_mac_address()
341+ self.assertEqual(
342+ {
343+ 'id': interface,
344+ 'type': 'ethernet',
345+ 'ethernet_mac_address': mac,
346+ },
347+ generate_ethernet_link_entry(interface, mac))
348+
349+
350+class TestGenerateNetworkingConfig(MAASServerTestCase):
351+
352+ def patch_interfaces(self, interface_mac_pairs):
353+ patch = self.patch_autospec(
354+ networking_preseed, 'extract_network_interfaces')
355+ patch.return_value = interface_mac_pairs
356+ return patch
357+
358+ def test__returns_config_dict(self):
359+ self.patch_interfaces([])
360+ config = generate_networking_config(factory.make_Node())
361+ self.assertIsInstance(config, dict)
362+ self.assertEqual("MAAS", config['provider'])
363+
364+ def test__includes_links(self):
365+ node = factory.make_Node()
366+ interface = factory.make_name('eth')
367+ mac = factory.make_mac_address()
368+ patch = self.patch_interfaces([(interface, mac)])
369+
370+ config = generate_networking_config(node)
371+
372+ self.assertThat(patch, MockCalledOnceWith(node))
373+ self.assertEqual(
374+ [
375+ {
376+ 'id': interface,
377+ 'type': 'ethernet',
378+ 'ethernet_mac_address': mac,
379+ },
380+ ],
381+ config['network_info']['links'])
382+
383+ def test__includes_networks(self):
384+ # This section is not yet implemented, so expect an empty list.
385+ self.patch_interfaces([])
386+ config = generate_networking_config(factory.make_Node())
387+ self.assertEqual([], config['network_info']['networks'])
388+
389+ def test__includes_dns_servers(self):
390+ # This section is not yet implemented, so expect an empty list.
391+ self.patch_interfaces([])
392+ config = generate_networking_config(factory.make_Node())
393+ self.assertEqual([], config['network_info']['services'])