launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #12507
[Merge] lp:~jtv/maas/list-networks into lp:maas
Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/list-networks into lp:maas.
Commit message:
New module: list attached networks that a cluster controller might be in charge of.
The start_cluster_controller will need this to report its interface information to the region controller when registering.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jtv/maas/list-networks/+merge/126487
See commit message. Discussed with Julian. There are more direct ways to get interface information in python, but they seem to involve manually re-defining C-level struct layouts which may vary by architecture.
What you see here is a minimal parser that runs /sbin/ifconfig to see attached network interfaces. Both virtual and physical interfaces are considered, because in some environments MAAS may manage virtual machines.
I based the parser on the ifconfig output I could find. Output may differ in other systems, and the parser may need amending. But we're very much in incremental-improvement mode here.
Jeroen
--
https://code.launchpad.net/~jtv/maas/list-networks/+merge/126487
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/list-networks into lp:maas.
=== added file 'src/provisioningserver/network.py'
--- src/provisioningserver/network.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/network.py 2012-09-26 15:55:26 +0000
@@ -0,0 +1,105 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Discover networks attached to this cluster controller.
+
+A cluster controller uses this when registering itself with the region
+controller.
+"""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = [
+ 'discover_networks',
+ ]
+
+from io import BytesIO
+import os
+from subprocess import check_call
+
+
+class InterfaceInfo:
+ """The details of a network interface we are interested in."""
+
+ def __init__(self, interface):
+ self.interface = interface
+ self.ip = None
+ self.mask = None
+
+ def may_be_subnet(self):
+ """Could this be a subnet that MAAS is interested in?"""
+ return all([
+ self.interface != 'lo',
+ self.ip is not None,
+ self.mask is not None,
+ ])
+
+ def as_dict(self):
+ return {
+ 'interface': self.interface,
+ 'ip': self.ip,
+ 'mask': self.mask,
+ }
+
+
+def run_ifconfig():
+ """Run `ifconfig` to list active interfaces. Return output."""
+ env = dict(os.environ, LC_ALL='C')
+ stdout = BytesIO()
+ check_call(['/sbin/ifconfig'], env=env, stdout=stdout)
+ stdout.seek(0)
+ return stdout.read().decode('ascii')
+
+
+def extract_ip_and_mask(line):
+ """Get IP address and subnet mask from an inet address line."""
+ # This line consists of key:value pairs separated by double spaces.
+ # The "inet addr" key contains a space. There is typically a
+ # trailing separator.
+ settings = dict(
+ tuple(pair.split(':', 1))
+ for pair in line.split(' '))
+ return settings.get('inet addr'), settings.get('Mask')
+
+
+def parse_stanza(stanza):
+ """Return a :class:`InterfaceInfo` representing this ifconfig stanza.
+
+ May return `None` if it's immediately clear that the interface is not
+ relevant for MAAS.
+ """
+ lines = [line.strip() for line in stanza.splitlines()]
+ header = lines[0]
+ if 'Link encap:Ethernet' not in header:
+ return None
+ info = InterfaceInfo(header.split()[0])
+ for line in lines[1:]:
+ if line.split()[0] == 'inet':
+ info.ip, info.mask = extract_ip_and_mask(line)
+ return info
+
+
+def split_stanzas(output):
+ """Split `ifconfig` output into stanzas, one per interface."""
+ stanzas = [stanza.strip() for stanza in output.strip().split('\n\n')]
+ return [stanza for stanza in stanzas if len(stanza) > 0]
+
+
+def parse_ifconfig(output):
+ """List `InterfaceInfo` for each interface found in `ifconfig` output."""
+ infos = [parse_stanza(stanza) for stanza in split_stanzas(output)]
+ return [info for info in infos if info is not None]
+
+
+def discover_networks():
+ """Find the networks attached to this system."""
+ output = run_ifconfig()
+ return [
+ interface
+ for interface in parse_ifconfig(output)
+ if interface.may_be_subnet()]
=== added file 'src/provisioningserver/tests/test_network.py'
--- src/provisioningserver/tests/test_network.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/tests/test_network.py 2012-09-26 15:55:26 +0000
@@ -0,0 +1,287 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the `network` module."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = []
+
+from random import (
+ choice,
+ randint,
+ )
+
+from maastesting.factory import factory
+from maastesting.testcase import TestCase
+from provisioningserver import network
+
+
+class FakeCheckCall:
+ """Test double for `check_call`."""
+
+ def __init__(self, output_text):
+ self.output_text = output_text
+ self.calls = []
+
+ def __call__(self, command, stdout=None, env=None):
+ stdout.write(self.output_text.encode('ascii'))
+ self.calls.append(dict(command=command, env=env))
+ return 0
+
+
+def make_address_line(**kwargs):
+ """Create an inet address line."""
+ # First word on this line is inet or inet6.
+ kwargs.setdefault('inet', 'inet')
+ kwargs.setdefault('broadcast', '10.255.255.255')
+ kwargs.setdefault('mask', '255.0.0.0')
+ items = [
+ "%(inet)s addr:%(ip)s"
+ ]
+ if len(kwargs['broadcast']) > 0:
+ items.append("Bcast:%(broadcast)s")
+ items.append("Mask:%(mask)s")
+ return ' '.join(items) % kwargs
+
+
+def make_stats_line(direction, **kwargs):
+ """Create one of the incoming/outcoming packet-count lines."""
+ assert direction in {'RX', 'TX'}
+ if direction == 'RX':
+ variable_field = 'frame'
+ else:
+ variable_field = 'carrier'
+ kwargs.setdefault('variable_field', variable_field)
+ kwargs.setdefault('packets', randint(0, 100000))
+ kwargs.setdefault('errors', randint(0, 100))
+ kwargs.setdefault('dropped', randint(0, 100))
+ kwargs.setdefault('overruns', randint(0, 100))
+ kwargs.setdefault('variable', randint(0, 100))
+
+ return " ".join([
+ direction,
+ "packets:%(packets)d",
+ "errors:%(errors)d",
+ "dropped:%(dropped)d",
+ "overruns:%(overruns)d",
+ "%(variable_field)s:%(variable)d"
+ ]) % kwargs
+
+
+def make_payload_stats(direction, **kwargs):
+ assert direction in {'RX', 'TX'}
+ kwargs.setdefault('bytes', randint(0, 1000000))
+ kwargs.setdefault('bigger_unit', randint(10, 10240) / 10.0)
+ kwargs.setdefault('unit', choice(['B', 'KB', 'GB']))
+ return " ".join([
+ direction,
+ "bytes:%(bytes)s",
+ "(%(bigger_unit)d %(unit)s)",
+ ]) % kwargs
+
+
+def make_stanza(**kwargs):
+ """Create an ifconfig output stanza.
+
+ Variable values can be specified, but will be given random values by
+ default. Values that interfaces may not have, such as broadcast
+ address or allocated interrupt, may be set to the empty string to
+ indicate that they should be left out of the output.
+ """
+ kwargs.setdefault('interface', factory.make_name('eth'))
+ kwargs.setdefault('encapsulation', 'Ethernet')
+ kwargs.setdefault('mac', factory.getRandomMACAddress())
+ kwargs.setdefault('ip', factory.getRandomIPAddress())
+ kwargs.setdefault('broadcast', factory.getRandomIPAddress())
+ kwargs.setdefault('mtu', randint(100, 10000))
+ kwargs.setdefault('rxline', make_stats_line('RX', **kwargs))
+ kwargs.setdefault('txline', make_stats_line('TX', **kwargs))
+ kwargs.setdefault('collisions', randint(0, 100))
+ kwargs.setdefault('txqueuelen', randint(0, 100))
+ kwargs.setdefault('rxbytes', make_payload_stats('RX', **kwargs))
+ kwargs.setdefault('txbytes', make_payload_stats('TX', **kwargs))
+ kwargs.setdefault('interrupt', randint(1, 30))
+
+ # The real-life output seems to have two trailing spaces here.
+ header = "%(interface)s Link encap:%(encapsulation)s HWaddr %(mac)s "
+ body_lines = [
+ "UP BROADCAST MULTICAST MTU:%(mtu)d Metric:1",
+ ]
+ if len(kwargs['ip']) > 0:
+ body_lines.append(make_address_line(inet='inet', **kwargs))
+ body_lines += [
+ "%(rxline)s",
+ "%(txline)s",
+ # This line has a trailing space in the real-life output.
+ "collisions:%(collisions)d txqueuelen:%(txqueuelen)d ",
+ "%(rxbytes)s %(txbytes)s",
+ ]
+ if kwargs['interrupt'] != '':
+ body_lines.append("Interrupt:%(interrupt)d")
+
+ text = '\n'.join(
+ [header] +
+ [(10 * " ") + line for line in body_lines])
+ return (text + "\n") % kwargs
+
+
+def join_stanzas(stanzas):
+ """Format a sequence of interface stanzas like ifconfig does."""
+ return '\n'.join(stanzas) + '\n'
+
+
+# Tragically can't afford to indent and then dedent() this. This output
+# isn't entirely realistic: the real output has trailing spaces here and
+# there, which we don't tolerate in our source code.
+sample_output = """\
+eth0 Link encap:Ethernet HWaddr 00:25:bc:e6:0b:c2
+ UP BROADCAST MULTICAST MTU:1500 Metric:1
+ RX packets:0 errors:0 dropped:0 overruns:0 frame:0
+ TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
+ collisions:0 txqueuelen:1000
+ RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
+
+eth1 Link encap:Ethernet HWaddr 00:14:73:ad:29:62
+ inet addr:192.168.12.103 Bcast:192.168.12.255 Mask:255.255.255.0
+ inet6 addr: fe81::210:9ff:fcd3:6120/64 Scope:Link
+ UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
+ RX packets:5272 errors:1 dropped:0 overruns:0 frame:3274
+ TX packets:5940 errors:2 dropped:0 overruns:0 carrier:0
+ collisions:0 txqueuelen:1000
+ RX bytes:2254714 (2.2 MB) TX bytes:4045385 (4.0 MB)
+ Interrupt:22
+
+lo Link encap:Local Loopback
+ inet addr:127.0.0.1 Mask:255.0.0.0
+ inet6 addr: ::1/128 Scope:Host
+ UP LOOPBACK RUNNING MTU:16436 Metric:1
+ RX packets:297493 errors:0 dropped:0 overruns:0 frame:0
+ TX packets:297493 errors:0 dropped:0 overruns:0 carrier:0
+ collisions:0 txqueuelen:0
+ RX bytes:43708 (43.7 KB) TX bytes:43708 (43.7 KB)
+
+maasbr0 Link encap:Ethernet HWaddr 46:a1:20:8b:77:14
+ inet addr:192.168.64.1 Bcast:192.168.64.255 Mask:255.255.255.0
+ UP BROADCAST MULTICAST MTU:1500 Metric:1
+ RX packets:0 errors:0 dropped:0 overruns:0 frame:0
+ TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
+ collisions:0 txqueuelen:0
+ RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
+
+virbr0 Link encap:Ethernet HWaddr 68:14:23:c0:6d:bf
+ inet addr:192.168.80.1 Bcast:192.168.80.255 Mask:255.255.255.0
+ UP BROADCAST MULTICAST MTU:1500 Metric:1
+ RX packets:0 errors:0 dropped:0 overruns:0 frame:0
+ TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
+ collisions:0 txqueuelen:0
+ RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
+
+ """
+
+
+class TestNetworks(TestCase):
+
+ def test_run_ifconfig_returns_ifconfig_output(self):
+ text = join_stanzas([make_stanza()])
+ self.patch(network, 'check_call', FakeCheckCall(text))
+ self.assertEqual(text, network.run_ifconfig())
+
+ def test_parse_ifconfig_produces_interface_info(self):
+ num_interfaces = randint(1, 3)
+ text = join_stanzas([
+ make_stanza()
+ for counter in range(num_interfaces)])
+ info = network.parse_ifconfig(text)
+ self.assertEqual(num_interfaces, len(info))
+ self.assertIsInstance(info[0], network.InterfaceInfo)
+
+ def test_parse_stanza_reads_interface_with_ip_and_interrupt(self):
+ parms = {
+ 'interface': factory.make_name('eth'),
+ 'ip': factory.getRandomIPAddress(),
+ 'mask': '255.255.255.128',
+ }
+ info = network.parse_stanza(make_stanza(**parms))
+ self.assertEqual(parms, info.as_dict())
+
+ def test_parse_stanza_reads_interface_without_interrupt(self):
+ parms = {
+ 'interface': factory.make_name('eth'),
+ 'ip': factory.getRandomIPAddress(),
+ 'mask': '255.255.255.128',
+ 'interrupt': '',
+ }
+ info = network.parse_stanza(make_stanza(**parms))
+ expected = parms.copy()
+ del expected['interrupt']
+ self.assertEqual(expected, info.as_dict())
+
+ def test_parse_stanza_reads_interface_without_ip(self):
+ parms = {
+ 'interface': factory.make_name('eth'),
+ 'ip': '',
+ }
+ info = network.parse_stanza(make_stanza(**parms))
+ expected = parms.copy()
+ expected['ip'] = None
+ expected['mask'] = None
+ self.assertEqual(expected, info.as_dict())
+
+ def test_parse_stanza_returns_nothing_for_loopback(self):
+ parms = {
+ 'interface': 'lo',
+ 'ip': '127.1.2.3',
+ 'mask': '255.0.0.0',
+ 'encapsulation': 'Local Loopback',
+ 'broadcast': '',
+ 'interrupt': '',
+ }
+ self.assertIsNone(network.parse_stanza(make_stanza(**parms)))
+
+ def test_split_stanzas_returns_empty_for_empty_input(self):
+ self.assertEqual([], network.split_stanzas(''))
+
+ def test_split_stanzas_returns_single_stanza(self):
+ stanza = make_stanza()
+ self.assertEqual([stanza.strip()], network.split_stanzas(stanza))
+
+ def test_split_stanzas_splits_multiple_stanzas(self):
+ stanzas = [make_stanza() for counter in range(3)]
+ full_output = join_stanzas(stanzas)
+ self.assertEqual(
+ [stanza.strip() for stanza in stanzas],
+ network.split_stanzas(full_output))
+
+ def test_discover_networks_returns_suitable_interfaces(self):
+ params = {
+ 'interface': factory.make_name('eth'),
+ 'ip': factory.getRandomIPAddress(),
+ 'mask': '255.255.255.0',
+ }
+ regular_interface = make_stanza(**params)
+ loopback = make_stanza(
+ interface='lo', encapsulation='Local loopback', broadcast='',
+ interrupt='')
+ disabled_interface = make_stanza(ip='', broadcast='', mask='')
+
+ text = join_stanzas([regular_interface, loopback, disabled_interface])
+ self.patch(network, 'run_ifconfig').return_value = text
+
+ interfaces = network.discover_networks()
+
+ self.assertEqual(
+ [params],
+ [interface.as_dict() for interface in interfaces])
+
+ def test_discover_networks_processes_real_ifconfig_output(self):
+ self.patch(network, 'run_ifconfig').return_value = sample_output
+ info = network.discover_networks()
+ self.assertEqual(
+ ['eth1', 'maasbr0', 'virbr0'],
+ [interface.interface for interface in info])