← Back to team overview

launchpad-reviewers team mailing list archive

[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])