Merge lp:~mpontillo/maas/autocreate-fabrics-and-vlans into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 4408
Proposed branch: lp:~mpontillo/maas/autocreate-fabrics-and-vlans
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 1069 lines (+553/-100)
12 files modified
src/maasserver/forms.py (+108/-50)
src/maasserver/models/fabric.py (+23/-0)
src/maasserver/models/nodegroupinterface.py (+2/-1)
src/maasserver/models/tests/test_nodegroupinterface.py (+12/-12)
src/maasserver/models/vlan.py (+8/-0)
src/maasserver/tests/test_forms_nodegroupinterface.py (+107/-2)
src/maasserver/utils/interfaces.py (+3/-1)
src/provisioningserver/network.py (+146/-17)
src/provisioningserver/rpc/clusterservice.py (+1/-0)
src/provisioningserver/tests/test_network.py (+33/-1)
src/provisioningserver/utils/ipaddr.py (+63/-4)
src/provisioningserver/utils/tests/test_ipaddr.py (+47/-12)
To merge this branch: bzr merge lp:~mpontillo/maas/autocreate-fabrics-and-vlans
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
LaMont Jones (community) Approve
Review via email: mp+275516@code.launchpad.net

Commit message

Automatically create VLANs and Fabrics when cluster interfaces are created. (and when it's appropriate)

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

This still needs more unit tests written. But I'd like some additional eyes on this code before I move forward.

I've been testing this end-to-end and it seems to do what we want, at least for creating VLANs and fabrics automatically when the cluster registers.

Adding cluster interfaces by hand, (and editing existing interfaces to make changes that SHOULD probably put them in a different VLAN or Fabric) will most likely do the wrong thing still.

Revision history for this message
LaMont Jones (lamont) wrote :

Nothing leaps out at me as broken (other than collision detection for the multiple ip addresses) - I'd prefer to not see the randint() call at all, for repeatability sake (which might allow a change in the testcase.)

review: Needs Fixing
Revision history for this message
LaMont Jones (lamont) wrote :

looks good.

review: Approve
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good. Just move that nasty hash calculation to its own function.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/forms.py'
2--- src/maasserver/forms.py 2015-10-23 18:56:34 +0000
3+++ src/maasserver/forms.py 2015-10-23 21:41:16 +0000
4@@ -46,9 +46,10 @@
5 import collections
6 from collections import Counter
7 from functools import partial
8+import hashlib
9 import json
10+import os
11 import pipes
12-from random import randint
13 import re
14
15 from crochet import TimeoutError
16@@ -126,6 +127,7 @@
17 Config,
18 Device,
19 DownloadProgress,
20+ Fabric,
21 Filesystem,
22 Interface,
23 LargeFile,
24@@ -142,6 +144,7 @@
25 SSLKey,
26 Tag,
27 VirtualBlockDevice,
28+ VLAN,
29 VolumeGroup,
30 Zone,
31 )
32@@ -192,11 +195,15 @@
33 valid_ipv6,
34 )
35 from provisioningserver.logger import get_maas_logger
36-from provisioningserver.network import filter_likely_unmanaged_networks
37+from provisioningserver.network import (
38+ filter_and_annotate_networks,
39+ sort_networks_by_priority,
40+)
41 from provisioningserver.rpc.exceptions import (
42 NoConnectionsAvailable,
43 NoSuchOperatingSystem,
44 )
45+from provisioningserver.utils.ipaddr import get_vid_from_ifname
46 from provisioningserver.utils.network import (
47 ip_range_within_network,
48 make_network,
49@@ -1546,35 +1553,6 @@
50 return True
51
52
53-def disambiguate_name(original_name, ip_address):
54- """Return a unique variant of `original_name` for a cluster interface.
55-
56- This function has no knowledge of other existing cluster interfaces. It
57- disambiguates based purely on the given data and random numbers.
58-
59- :param original_name: The originally proposed name for the cluster
60- interface, which presumably turned out to be ambiguous.
61- :param ip_address: IP address for the cluster interface (either as a
62- string or as an `IPAddress`). Used to determine whether the interface
63- is an IPv4 one or an IPv6 one.
64- :return: A version of `original_name` with a disambiguating suffix. The
65- suffix contains both the IP version (`ipv4` or `ipv6`) and possibly a
66- random part to avoid further clashes.
67- """
68- ip_version = IPAddress(ip_address).version
69- assert ip_version in (4, 6)
70- if ip_version == 6:
71- # IPv6 cluster interface. In principle there could be many of these
72- # on the same network interface, so add a random suffix.
73- suffix = 'ipv6-%d' % randint(10000, 99999)
74- else:
75- # IPv4 cluster interface. There can be only one of these on the
76- # network interface, so just suffixing '-ipv4' to the name should make
77- # it unique.
78- suffix = 'ipv4'
79- return '%s-%s' % (original_name, suffix)
80-
81-
82 class NodeGroupInterfaceForm(MAASModelForm):
83
84 management = forms.TypedChoiceField(
85@@ -1628,6 +1606,7 @@
86
87 subnet = None
88 if self.instance and self.instance.subnet and ip and subnet_mask:
89+ # Existing NodeGroupInterface, so just update the existing Subnet.
90 cidr = create_cidr(ip, subnet_mask)
91 subnet = self.instance.subnet
92 subnet.update_cidr(cidr)
93@@ -1635,14 +1614,55 @@
94 subnet.gateway_ip = router_ip
95 subnet.save()
96 elif subnet_mask:
97+ # New NodeGroupInterface, so create the Subnet (if we have enough
98+ # information).
99 cidr = create_cidr(ip, subnet_mask)
100- subnet, _ = Subnet.objects.get_or_create(
101+ subnet, created = Subnet.objects.get_or_create(
102 cidr=cidr, defaults={
103 'name': cidr,
104 'cidr': cidr,
105 'gateway_ip': router_ip,
106 'space': Space.objects.get_default_space()
107 })
108+ if created:
109+ # Check if a Subnet was already created for a matching
110+ # interface. (this could happen if multiple IP addresses
111+ # are assigned to the same interface.)
112+ vlan = None
113+ ifname = self.cleaned_data.get('interface')
114+ if self.instance:
115+ vlan = VLAN.objects.filter_by_nodegroup_interface(
116+ self.instance.nodegroup, ifname).order_by('id').first()
117+ if vlan is not None:
118+ # Found an existing VLAN that matches this interface.
119+ # No need to create a new one; just assign that VLAN
120+ # to the subnet that was found.
121+ subnet.vlan = vlan
122+ subnet.save()
123+ elif self.data.get('type') == 'ethernet.vlan':
124+ # If we're creating a VLAN, we must be intending to put
125+ # the VLAN on a Fabric that already exists.
126+ # Check the VLAN's parent interface to see if it is
127+ # already associated with a VLAN (and hence a Fabric).
128+ vid = self.data.get('vid', get_vid_from_ifname(ifname))
129+ parent = self.data.get('parent')
130+ fabric = Fabric.objects.filter_by_nodegroup_interface(
131+ self.instance.nodegroup, parent).first()
132+ if fabric is None:
133+ fabric = Fabric.objects.get_or_create_for_subnet(
134+ subnet)
135+ vlan = VLAN.objects.create(vid=vid, fabric=fabric)
136+ subnet.vlan = vlan
137+ subnet.save()
138+ else:
139+ # If we created a new Subnet, and it's *not* a tagged VLAN
140+ # (and we didn't find an existing interface above), that
141+ # means we need to create a new Fabric and place the
142+ # subnet in its default VLAN.
143+ fabric = Fabric.objects.get_or_create_for_subnet(
144+ subnet)
145+ subnet.vlan = fabric.get_default_vlan()
146+ subnet.save()
147
148 interface = super(NodeGroupInterfaceForm, self).save(*args, **kwargs)
149 if interface.subnet != subnet:
150@@ -1650,6 +1670,45 @@
151 interface.save()
152 return interface
153
154+ def _ensure_unique_cluster_interface_name(self, name, interface):
155+ """
156+ Ensures that the specified interface name is unique. Appends a portion
157+ of a unique sha256 hex string (and the IP version) if not.
158+
159+ :param name: The tentative "friendly" name of this cluster interface.
160+ :param interface: The name of the network interface.
161+ :return:str
162+ """
163+ # Get the cluster to which this interface is attached. There may not
164+ # be one, since it may be a placeholder instance that is still being
165+ # initialised by the same request that is also creating the interface.
166+ # In that case, self.instance.nodegroup will not be None, but rather
167+ # an ORM stub which crashes when accessed.
168+ cluster = get_one(
169+ NodeGroup.objects.filter(id=self.instance.nodegroup_id))
170+ if cluster is not None and interface:
171+ siblings = cluster.nodegroupinterface_set
172+ while siblings.filter(name=name).exists():
173+ # In case the interface name already exists, use a
174+ # cryptographic hash based on the interface name and address
175+ # (plus some random bytes for good measure).
176+ ip_address = self.cleaned_data['ip']
177+ ip_version = IPAddress(ip_address).version
178+ sha = hashlib.sha256()
179+ sha.update(name)
180+ sha.update(ip_address)
181+ sha.update(self.cleaned_data['subnet_mask'])
182+ sha.update(os.urandom(32))
183+ digest = sha.hexdigest()
184+ for i in range(4, 65):
185+ # The upper bound of this range() will be 64, so that
186+ # we can get the full sha256 hex string if needed.
187+ suffix = digest[:i]
188+ new_name = '%s-ipv%d-%s' % (name, ip_version, suffix)
189+ if not siblings.filter(name=new_name).exists():
190+ return new_name
191+ return name
192+
193 def compute_name(self):
194 """Return the value the `name` field should have.
195
196@@ -1670,20 +1729,9 @@
197 # compatibility with clients that expect the pre-1.6 behaviour, where
198 # the 'name' and 'interface' fields were the same thing.
199 interface = self.cleaned_data.get('interface')
200- name = make_name_from_interface(interface)
201- # Get the cluster to which this interface is attached. There may not
202- # be one, since it may be a placeholder instance that is still being
203- # initialised by the same request that is also creating the interface.
204- # In that case, self.instance.nodegroup will not be None, but rather
205- # an ORM stub which crashes when accessed.
206- cluster = get_one(
207- NodeGroup.objects.filter(id=self.instance.nodegroup_id))
208- if cluster is not None and interface:
209- siblings = cluster.nodegroupinterface_set
210- if siblings.filter(name=name).exists():
211- # This name is already in use. Add a suffix to make it unique.
212- return disambiguate_name(name, self.cleaned_data['ip'])
213-
214+ name = make_name_from_interface(
215+ interface, alias=self.data.get('alias'))
216+ name = self._ensure_unique_cluster_interface_name(name, interface)
217 return name
218
219 def get_duplicate_fqdns(self):
220@@ -1706,6 +1754,18 @@
221
222 return set(duplicates)
223
224+ def clean_interface(self):
225+ # Interface names must be consistent, so that if eth0 and eth0:1
226+ # are added as cluster interfaces, they can be associated with the
227+ # same VLANs. Save the alias so that we can use it in to
228+ # disambiguate the interface name.
229+ ifname = self.cleaned_data['interface']
230+ if ':' in ifname:
231+ alias = ifname.strip().split(':', 1)[1]
232+ self.data['alias'] = alias
233+ ifname = ifname.strip().split(':')[0]
234+ return ifname
235+
236 def clean_management(self):
237 management = self.cleaned_data['management']
238
239@@ -2018,7 +2078,7 @@
240
241 # Try to only manage physical Ethernet interfaces.
242 ip_addr_json = self.data.get('ip_addr_json', None)
243- interfaces = filter_likely_unmanaged_networks(interfaces, ip_addr_json)
244+ interfaces = filter_and_annotate_networks(interfaces, ip_addr_json)
245
246 for interface in interfaces:
247 validate_nodegroupinterface_definition(interface)
248@@ -2033,9 +2093,7 @@
249 # unique names for cluster interfaces on the same network interface,
250 # the IPv4 one will get first stab at getting the exact same name as
251 # the network interface.
252- interfaces = sorted(
253- self.cleaned_data['interfaces'],
254- key=lambda definition: IPAddress(definition['ip']).version)
255+ interfaces = sort_networks_by_priority(self.cleaned_data['interfaces'])
256 for interface in interfaces:
257 instance = NodeGroupInterface(nodegroup=nodegroup)
258 form = NodeGroupInterfaceForm(data=interface, instance=instance)
259
260=== modified file 'src/maasserver/models/fabric.py'
261--- src/maasserver/models/fabric.py 2015-10-21 21:13:01 +0000
262+++ src/maasserver/models/fabric.py 2015-10-23 21:41:16 +0000
263@@ -70,6 +70,29 @@
264 fabric._create_default_vlan()
265 return fabric
266
267+ def get_or_create_for_subnet(self, subnet):
268+ """Given an existing fabric_id (by default, the default fabric)
269+ creates and returns a new Fabric if there is an existing Subnet in
270+ the fabric already. Exclude the specified subnet (which will be one
271+ that was just created).
272+ """
273+ from maasserver.models import Subnet
274+ default_fabric = self.get_default_fabric()
275+ if Subnet.objects.filter(
276+ vlan__fabric=default_fabric).exclude(
277+ id=subnet.id).count() == 0:
278+ return default_fabric
279+ else:
280+ return Fabric.objects.create()
281+
282+ def filter_by_nodegroup_interface(self, nodegroup, ifname):
283+ """Query for the Fabric associated with the specified NodeGroup,
284+ where the NodeGroupInterface matches the specified name.
285+ """
286+ return self.filter(
287+ vlan__subnet__nodegroupinterface__nodegroup=nodegroup,
288+ vlan__subnet__nodegroupinterface__interface=ifname)
289+
290 def get_fabric_or_404(self, id, user, perm):
291 """Fetch a `Fabric` by its id. Raise exceptions if no `Fabric` with
292 this id exist or if the provided user has not the required permission
293
294=== modified file 'src/maasserver/models/nodegroupinterface.py'
295--- src/maasserver/models/nodegroupinterface.py 2015-09-24 16:22:12 +0000
296+++ src/maasserver/models/nodegroupinterface.py 2015-10-23 21:41:16 +0000
297@@ -400,7 +400,8 @@
298 return
299 ip_version = IPAddress(self.ip).version
300 similar_interfaces = self.nodegroup.nodegroupinterface_set.filter(
301- interface=self.interface)
302+ interface=self.interface).exclude(
303+ management=NODEGROUPINTERFACE_MANAGEMENT.UNMANAGED)
304 if self.id is not None:
305 similar_interfaces = similar_interfaces.exclude(id=self.id)
306 potential_clashes = [
307
308=== modified file 'src/maasserver/models/tests/test_nodegroupinterface.py'
309--- src/maasserver/models/tests/test_nodegroupinterface.py 2015-09-24 16:22:12 +0000
310+++ src/maasserver/models/tests/test_nodegroupinterface.py 2015-10-23 21:41:16 +0000
311@@ -627,25 +627,25 @@
312 NodeGroupInterface.objects.filter(interface=net_interface),
313 HasLength(2))
314
315- def test_validation_rejects_two_IPv4_interfaces_on_net_interface(self):
316+ def test_rejects_two_managed_IPv4_interfaces_on_net_interface(self):
317 cluster = factory.make_NodeGroup()
318 net_interface = factory.make_name('eth')
319 factory.make_NodeGroupInterface(
320 cluster, interface=net_interface,
321+ management=NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS,
322 network=factory.make_ipv4_network())
323 network = factory.make_ipv4_network()
324 extra_interface = NodeGroupInterface(
325 nodegroup=cluster, interface=net_interface,
326+ management=NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS,
327 ip=factory.pick_ip_in_network(network))
328
329 error = self.assertRaises(ValidationError, extra_interface.save)
330- self.assertEqual(
331- [
332+ self.assertThat(
333+ error.messages, Contains(
334 "Another cluster interface already connects "
335 "network interface %s to an IPv4 network."
336- % net_interface
337- ],
338- error.messages)
339+ % net_interface))
340
341 def test_validation_accepts_two_IPv6_interfaces_on_net_interface(self):
342 cluster = factory.make_NodeGroup()
343@@ -660,29 +660,29 @@
344 extra_interface.save()
345 self.assertThat(cluster.nodegroupinterface_set.all(), HasLength(2))
346
347- def test_validation_rejects_two_IPv6_static_ranges_on_net_interface(self):
348+ def test_rejects_two_managed_IPv6_static_ranges_on_net_interface(self):
349 cluster = factory.make_NodeGroup()
350 net_interface = factory.make_name('eth')
351 factory.make_NodeGroupInterface(
352 cluster, interface=net_interface,
353+ management=NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS,
354 network=factory.make_ipv6_network(slash=64))
355 network = factory.make_ipv6_network(slash=64)
356 static_low = unicode(IPAddress(network.first + 1))
357 static_high = unicode(IPAddress(network.last - 1))
358 extra_interface = NodeGroupInterface(
359 nodegroup=cluster, interface=net_interface,
360+ management=NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS,
361 ip=factory.pick_ip_in_network(network),
362 static_ip_range_low=static_low,
363 static_ip_range_high=static_high)
364
365 error = self.assertRaises(ValidationError, extra_interface.save)
366- self.assertEqual(
367- [
368+ self.assertThat(
369+ error.messages, Contains(
370 "Another cluster interface with a static address range "
371 "already connects network interface %s to an IPv6 network."
372- % net_interface
373- ],
374- error.messages)
375+ % net_interface))
376
377 def test_validation_knows_update_from_new_interface(self):
378 cluster = factory.make_NodeGroup()
379
380=== modified file 'src/maasserver/models/vlan.py'
381--- src/maasserver/models/vlan.py 2015-10-09 06:06:47 +0000
382+++ src/maasserver/models/vlan.py 2015-10-23 21:41:16 +0000
383@@ -49,6 +49,14 @@
384 from maasserver.models.fabric import Fabric
385 return Fabric.objects.get_default_fabric().get_default_vlan()
386
387+ def filter_by_nodegroup_interface(self, nodegroup, ifname):
388+ """Query fot the VLAN that matches the specified NodeGroup, whose
389+ interface matches the specified name.
390+ """
391+ return self.filter(
392+ subnet__nodegroupinterface__nodegroup=nodegroup,
393+ subnet__nodegroupinterface__interface=ifname)
394+
395
396 class VLAN(CleanSave, TimestampedModel):
397 """A `VLAN`.
398
399=== modified file 'src/maasserver/tests/test_forms_nodegroupinterface.py'
400--- src/maasserver/tests/test_forms_nodegroupinterface.py 2015-09-08 18:41:57 +0000
401+++ src/maasserver/tests/test_forms_nodegroupinterface.py 2015-10-23 21:41:16 +0000
402@@ -23,7 +23,11 @@
403 ERROR_MESSAGE_STATIC_RANGE_IN_USE,
404 NodeGroupInterfaceForm,
405 )
406-from maasserver.models import NodeGroupInterface
407+from maasserver.models import (
408+ Fabric,
409+ NodeGroupInterface,
410+ VLAN,
411+)
412 from maasserver.models.staticipaddress import StaticIPAddress
413 from maasserver.testing.factory import factory
414 from maasserver.testing.testcase import MAASServerTestCase
415@@ -182,7 +186,9 @@
416 data=int_settings, instance=make_ngi_instance(cluster))
417 self.assertTrue(form.is_valid())
418 interface = form.save()
419- self.assertEqual('%s-ipv4' % int_settings['interface'], interface.name)
420+ self.assertThat(
421+ interface.name,
422+ StartsWith('%s-ipv4-' % int_settings['interface']))
423
424 def test__disambiguates_IPv6_interface_with_ipv6_suffix(self):
425 cluster = factory.make_NodeGroup()
426@@ -458,3 +464,102 @@
427 # print(list(Subnet.objects.all()))
428 # self.assertThat(ngi.network, Equals(new_network.cidr))
429 self.assertThat(Subnet.objects.count(), Equals(1))
430+
431+ def test_multiple_subnets_on_single_interface_uses_existing_vlan(self):
432+ ng = factory.make_NodeGroup()
433+ ngi1 = NodeGroupInterface(nodegroup=ng)
434+ form = NodeGroupInterfaceForm(data=dict(
435+ interface='eth0', ip='192.168.0.1', subnet_mask='255.255.255.0'),
436+ instance=ngi1)
437+ self.assertThat(form.is_valid(), Equals(True))
438+ ngi1 = form.save()
439+ self.assertIsNotNone(ngi1)
440+ ngi2 = NodeGroupInterface(nodegroup=ng)
441+ form = NodeGroupInterfaceForm(data=dict(
442+ interface='eth0', ip='192.168.1.1', subnet_mask='255.255.255.0'),
443+ instance=ngi2)
444+ self.assertThat(form.is_valid(), Equals(True))
445+ ngi2 = form.save()
446+ self.assertIsNotNone(ngi2)
447+ self.assertThat(VLAN.objects.all().count(), Equals(1))
448+ self.assertThat(ngi1.vlan, Equals(ngi2.vlan))
449+
450+ def test_subnet_vlan_creation_uses_default_fabric_if_empty(self):
451+ ng = factory.make_NodeGroup()
452+ ngi1 = NodeGroupInterface(nodegroup=ng)
453+ form = NodeGroupInterfaceForm(data=dict(
454+ interface='eth0', ip='192.168.0.1', subnet_mask='255.255.255.0'),
455+ instance=ngi1)
456+ self.assertThat(form.is_valid(), Equals(True))
457+ ngi1 = form.save()
458+ self.assertIsNotNone(ngi1)
459+ self.assertThat(Fabric.objects.all().count(), Equals(1))
460+ self.assertThat(ngi1.vlan.fabric.id, Equals(0))
461+
462+ def test_creates_new_fabric_if_alt_subnet_exists_in_default_fabric(self):
463+ ng = factory.make_NodeGroup()
464+ ngi1 = NodeGroupInterface(nodegroup=ng)
465+ form = NodeGroupInterfaceForm(data=dict(
466+ interface='eth0', ip='192.168.0.1', subnet_mask='255.255.255.0'),
467+ instance=ngi1)
468+ self.assertThat(form.is_valid(), Equals(True))
469+ ngi1 = form.save()
470+ self.assertIsNotNone(ngi1)
471+ self.assertThat(Fabric.objects.all().count(), Equals(1))
472+ self.assertThat(ngi1.vlan.fabric.id, Equals(0))
473+ ngi2 = NodeGroupInterface(nodegroup=ng)
474+ form = NodeGroupInterfaceForm(data=dict(
475+ interface='eth1', ip='192.168.1.1', subnet_mask='255.255.255.0'),
476+ instance=ngi2)
477+ self.assertThat(form.is_valid(), Equals(True))
478+ ngi2 = form.save()
479+ self.assertIsNotNone(ngi2)
480+ self.assertThat(Fabric.objects.all().count(), Equals(2))
481+ # The first NodeGroupInterface we saved should be using the default
482+ # Fabric
483+ self.assertThat(ngi1.vlan.fabric.id, Equals(0))
484+ self.assertIsNotNone(ngi2.vlan.fabric)
485+
486+ def test_creates_vlan_interface_if_interface_type_and_parent_known(self):
487+ ng = factory.make_NodeGroup()
488+ ngi1 = NodeGroupInterface(nodegroup=ng)
489+ form = NodeGroupInterfaceForm(data=dict(
490+ interface='eth0', ip='192.168.0.1', subnet_mask='255.255.255.0'),
491+ instance=ngi1)
492+ self.assertThat(form.is_valid(), Equals(True))
493+ ngi1 = form.save()
494+ self.assertIsNotNone(ngi1)
495+ self.assertThat(Fabric.objects.all().count(), Equals(1))
496+ self.assertThat(ngi1.vlan.fabric.id, Equals(0))
497+ ngi2 = NodeGroupInterface(nodegroup=ng)
498+ form = NodeGroupInterfaceForm(data=dict(
499+ interface='vlan12', ip='192.168.1.1', subnet_mask='255.255.255.0',
500+ parent='eth0', type='ethernet.vlan'), instance=ngi2)
501+ self.assertThat(form.is_valid(), Equals(True))
502+ ngi2 = form.save()
503+ self.assertIsNotNone(ngi2)
504+ self.assertThat(Fabric.objects.all().count(), Equals(1))
505+ self.assertThat(VLAN.objects.filter(vid=12).count(), Equals(1))
506+
507+ def test_creates_vlan_plus_new_fabric_if_no_parent_untagged_exists(self):
508+ ng = factory.make_NodeGroup()
509+ ngi1 = NodeGroupInterface(nodegroup=ng)
510+ form = NodeGroupInterfaceForm(data=dict(
511+ interface='eth0', ip='192.168.0.1', subnet_mask='255.255.255.0'),
512+ instance=ngi1)
513+ self.assertThat(form.is_valid(), Equals(True))
514+ ngi1 = form.save()
515+ self.assertIsNotNone(ngi1)
516+ self.assertThat(Fabric.objects.all().count(), Equals(1))
517+ self.assertThat(ngi1.vlan.fabric.id, Equals(0))
518+ ngi2 = NodeGroupInterface(nodegroup=ng)
519+ form = NodeGroupInterfaceForm(data=dict(
520+ interface='eth0.12', ip='192.168.1.1', subnet_mask='255.255.255.0',
521+ type='ethernet.vlan'), instance=ngi2)
522+ self.assertThat(form.is_valid(), Equals(True))
523+ ngi2 = form.save()
524+ self.assertIsNotNone(ngi2)
525+ self.assertThat(Fabric.objects.all().count(), Equals(2))
526+ # Check that VLAN 12 was created on the non-default VLAN.
527+ self.assertThat(VLAN.objects.filter(
528+ vid=12, fabric__id__gt=0).count(), Equals(1))
529
530=== modified file 'src/maasserver/utils/interfaces.py'
531--- src/maasserver/utils/interfaces.py 2014-11-13 20:11:48 +0000
532+++ src/maasserver/utils/interfaces.py 2015-10-23 21:41:16 +0000
533@@ -21,7 +21,7 @@
534 import re
535
536
537-def make_name_from_interface(interface):
538+def make_name_from_interface(interface, alias=None):
539 """Generate a cluster interface name based on a network interface name.
540
541 The name is used as an identifier in API URLs, so awkward characters are
542@@ -30,6 +30,8 @@
543
544 If `interface` is `None`, or empty, a name will be made up.
545 """
546+ if alias:
547+ interface = "%s:%s" % (interface, alias)
548 if interface is None or interface == u'':
549 base_name = u'unnamed-%d' % randint(1000000, 9999999)
550 else:
551
552=== modified file 'src/provisioningserver/network.py'
553--- src/provisioningserver/network.py 2015-10-21 23:36:45 +0000
554+++ src/provisioningserver/network.py 2015-10-23 21:41:16 +0000
555@@ -149,11 +149,10 @@
556 return ip_addr_json
557
558
559-def _filter_likely_physical_networks_using_interface_name(networks):
560+def _filter_managed_networks_by_ifname(networks):
561 """
562- Given the specified list of networks and corresponding JSON, filters
563- the list of networks and returns any that may be physical interfaces
564- (based on the interface name).
565+ Given the specified list of networks, filters the list of networks and
566+ returns any that may be physical interfaces (based on the interface name).
567
568 :param networks: A list of network dictionaries. Must contain an
569 'interface' key containing the interface name.
570@@ -170,12 +169,90 @@
571 ]
572
573
574-def _filter_likely_physical_networks_using_json_data(networks, ip_addr_json):
575+def _annotate_network_with_interface_information(network, addr_info):
576+ """Adds a 'type' field to a specified dictionary which represents a network
577+ interface.
578+ """
579+ iface = addr_info.get(network['interface'], None)
580+ if iface is not None and 'type' in iface:
581+ network['type'] = iface['type']
582+ if 'vid' in iface:
583+ network['vid'] = iface['vid']
584+ if 'bridged_interfaces' in iface:
585+ network['bridged_interfaces'] = ' '.join(
586+ iface['bridged_interfaces'])
587+ if 'bonded_interfaces' in iface:
588+ network['bonded_interfaces'] = ' '.join(
589+ iface['bonded_interfaces'])
590+ if 'parent' in iface:
591+ network['parent'] = iface['parent']
592+ return network
593+
594+
595+def _bridges_a_physical_interface(ifname, addr_info):
596+ """Returns True if the bridge interface with the specified name bridges
597+ at least one physical Ethernet interface. Otherwise, returns False.
598+ """
599+ bridge_interface = addr_info.get(ifname)
600+ for interface_name in bridge_interface.get('bridged_interfaces', []):
601+ iface = addr_info.get(interface_name, {})
602+ if iface.get('type') == 'ethernet.physical':
603+ return True
604+ return False
605+
606+
607+def _belongs_to_a_vlan(ifname, addr_info):
608+ """Returns True if the interface with the specified name is needed
609+ because a VLAN interface depends on it.
610+ """
611+ for interface_name in addr_info:
612+ iface = addr_info.get(interface_name, {})
613+ if iface.get('type') == 'ethernet.vlan':
614+ if iface.get('parent') == ifname:
615+ return True
616+ return False
617+
618+
619+def _network_name(network):
620+ """Returns interface name for the specified network. (removes a trailing
621+ alias, if present.)
622+ """
623+ return network['interface'].split(':')[0]
624+
625+
626+def _should_manage_network(network, addr_info):
627+ """Returns True if this network should be managed; otherwise returns False.
628+ """
629+ ifname = _network_name(network)
630+ addrinfo = addr_info.get(ifname, {})
631+ iftype = addrinfo.get('type', '')
632+ # In general, only physical Ethernet interfaces, VLANs, and bonds
633+ # are going to be managed. Since they are most likely irrelevant, (and
634+ # we don't want them to create superfluous subnets) filter out virtual
635+ # interfaces (whose specific type cannot be determined) and bridges.
636+ # However, reconsider bridges as "possibly managed" if they are
637+ # present in support of a physical Ethernet device, or a VLAN is
638+ # defined on top of the bridge.
639+ return (
640+ addrinfo and
641+ (_belongs_to_a_vlan(ifname, addr_info) or
642+ iftype == 'ethernet.physical' or
643+ iftype == 'ethernet.vlan' or
644+ iftype == 'ethernet.bond' or
645+ (iftype == 'ethernet.bridge' and
646+ _bridges_a_physical_interface(ifname, addr_info))
647+ )
648+ )
649+
650+
651+def _filter_and_annotate_managed_networks(networks, ip_addr_json):
652 """
653 Given the specified list of networks and corresponding JSON, filters
654 the list of networks and returns any that are known to be physical
655 interfaces. (based on driver information gathered from /sys)
656
657+ Also annotates the list of networks with each network's type.
658+
659 :param networks: A list of network dictionaries. Must contain an
660 'interface' key containing the interface name.
661 :param ip_addr_json: A JSON string returned from `get_ip_addr_json()`.
662@@ -184,26 +261,26 @@
663 addr_info = json.loads(ip_addr_json)
664 assert isinstance(addr_info, dict)
665 return [
666- network
667+ _annotate_network_with_interface_information(network, addr_info)
668 for network in networks
669- if (network['interface'] in addr_info and
670- 'type' in addr_info[network['interface']] and
671- (addr_info[network['interface']]['type'] == 'ethernet.physical' or
672- addr_info[network['interface']]['type'] == 'ethernet.vlan' or
673- addr_info[network['interface']]['type'] == 'ethernet.bond'))
674+ if _should_manage_network(network, addr_info)
675 ]
676
677
678-def filter_likely_unmanaged_networks(networks, ip_addr_json=None):
679+def filter_and_annotate_networks(networks, ip_addr_json=None):
680 """
681 Given the specified list of networks and optional corresponding JSON,
682- filters the list of networks and returns any that are known to be physical
683- interfaces.
684+ filters the list of networks and returns any that may correspond to managed
685+ networks. (that is, any physical Ethernet interfaces, plus bonds and
686+ VLANs.)
687
688 If no interfaces are found, fall back to using the interface name to
689 filter the list in a reasonable manner. (this allows support for running
690 on LXCs, where all interfaces may be virtual.)
691
692+ Also annotates the list of networks with their type, and other metadata
693+ such as VLAN VID, bonded/bridged interfaces, or parent.
694+
695 :param networks: A list of network dictionaries. Must contain an
696 'interface' key containing the interface name.
697 :param ip_addr_json: A JSON string returned from `get_ip_addr_json()`.
698@@ -211,9 +288,9 @@
699 """
700 assert networks is not None
701 if ip_addr_json is None:
702- return _filter_likely_physical_networks_using_interface_name(networks)
703+ return _filter_managed_networks_by_ifname(networks)
704 else:
705- physical_networks = _filter_likely_physical_networks_using_json_data(
706+ physical_networks = _filter_and_annotate_managed_networks(
707 networks, ip_addr_json)
708 if len(physical_networks) > 0:
709 return physical_networks
710@@ -221,5 +298,57 @@
711 # Since we couldn't find anything, fall back to using the heuristic
712 # based on names. (we could be running inside a container with only
713 # virtual interfaces, etc.)
714- return _filter_likely_physical_networks_using_interface_name(
715+ return _filter_managed_networks_by_ifname(
716 networks)
717+
718+
719+def _get_interface_type_priority(iface):
720+ """Returns a sort key based on interface types we prefer to process
721+ first when adding them to a NodeGroup.
722+
723+ The most important thing is that we need to process VLANs last, since they
724+ require the underlying Fabric to be created first.
725+ """
726+ iftype = iface.get('type')
727+ # Physical interfaces first, followed by bonds, followed by bridges.
728+ # VLAN interfaces last.
729+ # This will ensure that underlying Fabric objects can be created before
730+ # any VLANs that may belong to each Fabric.
731+ if iftype == "ethernet.physical":
732+ return 0
733+ elif iftype == "ethernet.wireless":
734+ return 1
735+ elif iftype == "ethernet":
736+ return 2
737+ elif iftype == "ethernet.bond":
738+ return 3
739+ elif iftype == "ethernet.bridge":
740+ return 4
741+ elif iftype == "ethernet.vlan":
742+ return 5
743+ else:
744+ # We don't really care what the sort order is; they should be filtered
745+ # out anyway.
746+ return -1
747+
748+
749+def _network_priority_sort_key(iface):
750+ """Returns a sort key used for processing interfaces before adding them
751+ to a NodeGroup.
752+
753+ First sorts by interface type, then interface name, then address family.
754+ (Since MAAS usually manages IPv4 addresses, and we have a name
755+ disambiguation funciton that can produce somewhat unfriendly names,
756+ make sure the IPv4 interfaces get to go first.)
757+ """
758+ return (
759+ _get_interface_type_priority(iface),
760+ iface['interface'],
761+ IPAddress(iface['ip']).version
762+ )
763+
764+
765+def sort_networks_by_priority(networks):
766+ """Sorts the specified list of networks in the order in which we would
767+ prefer to add them to a NodeGroup."""
768+ return sorted(networks, key=_network_priority_sort_key)
769
770=== modified file 'src/provisioningserver/rpc/clusterservice.py'
771--- src/provisioningserver/rpc/clusterservice.py 2015-10-21 18:38:54 +0000
772+++ src/provisioningserver/rpc/clusterservice.py 2015-10-23 21:41:16 +0000
773@@ -525,6 +525,7 @@
774
775 networks = discover_networks()
776
777+ ip_addr_json = None
778 try:
779 ip_addr_json = get_ip_addr_json()
780 except ExternalProcessError as epe:
781
782=== modified file 'src/provisioningserver/tests/test_network.py'
783--- src/provisioningserver/tests/test_network.py 2015-10-21 23:36:45 +0000
784+++ src/provisioningserver/tests/test_network.py 2015-10-23 21:41:16 +0000
785@@ -25,7 +25,11 @@
786 AF_INET6,
787 )
788 from provisioningserver import network
789-from testtools.matchers import HasLength
790+from provisioningserver.network import sort_networks_by_priority
791+from testtools.matchers import (
792+ Equals,
793+ HasLength,
794+)
795
796
797 def make_inet_address(subnet=None):
798@@ -216,3 +220,31 @@
799 self.assertEqual(
800 network.filter_unique_networks(networks),
801 network.filter_unique_networks(reversed(networks)))
802+
803+
804+class TestSortNetworksByPriority(MAASTestCase):
805+
806+ def test__sorts_by_type_then_ip_version(self):
807+ interfaces = [
808+ {'ip': "2001:db8::1",
809+ 'type': "ethernet.vlan",
810+ 'interface': 'vlan40'},
811+ {'ip': "10.0.0.1",
812+ 'type': "ethernet.vlan",
813+ 'interface': 'vlan40'},
814+ {'ip': "2001:db8:1::1",
815+ 'type': "ethernet.physical",
816+ 'interface': 'eth1'},
817+ {'ip': "10.0.1.1",
818+ 'type': "ethernet.physical",
819+ 'interface': 'eth1'},
820+ {'ip': "10.0.2.1",
821+ 'type': "ethernet.bridge",
822+ 'interface': 'br0'},
823+ ]
824+ sorted_interfaces = sort_networks_by_priority(interfaces)
825+ self.expectThat(sorted_interfaces[0], Equals(interfaces[3]))
826+ self.expectThat(sorted_interfaces[1], Equals(interfaces[2]))
827+ self.expectThat(sorted_interfaces[2], Equals(interfaces[4]))
828+ self.expectThat(sorted_interfaces[3], Equals(interfaces[1]))
829+ self.expectThat(sorted_interfaces[4], Equals(interfaces[0]))
830
831=== modified file 'src/provisioningserver/utils/ipaddr.py'
832--- src/provisioningserver/utils/ipaddr.py 2015-10-22 00:48:38 +0000
833+++ src/provisioningserver/utils/ipaddr.py 2015-10-23 21:41:16 +0000
834@@ -108,7 +108,10 @@
835 string.strip, line.split(':'))
836
837 interface['index'] = int(index)
838- interface['name'] = name.split('@')[0]
839+ names = name.split('@')
840+ interface['name'] = names[0]
841+ if len(names) > 1:
842+ interface['parent'] = names[1]
843
844 # Now parse the <properties> part from above.
845 # This will be in the form "<FLAG1,FLAG2> key1 value1 key2 value2 ..."
846@@ -196,6 +199,35 @@
847 raise ValueError("Unknown IP address family: %s" % network.version)
848
849
850+def get_bonded_interfaces(ifname, sys_class_net="/sys/class/net"):
851+ """Returns a list of interface names which are part of the specified
852+ Ethernet bond.
853+
854+ :return:list
855+ """
856+ bonding_slaves_file = os.path.join(
857+ sys_class_net, ifname, 'bonding', 'slaves')
858+ if os.path.isfile(bonding_slaves_file):
859+ with open(bonding_slaves_file) as f:
860+ return f.read().split()
861+ else:
862+ return []
863+
864+
865+def get_bridged_interfaces(ifname, sys_class_net="/sys/class/net"):
866+ """Returns a list of interface names which are part of the specified
867+ Ethernet bridge interface.
868+
869+ :return:list
870+ """
871+ bridged_interfaces_dir = os.path.join(
872+ sys_class_net, ifname, 'brif')
873+ if os.path.isdir(bridged_interfaces_dir):
874+ return os.listdir(bridged_interfaces_dir)
875+ else:
876+ return []
877+
878+
879 def get_interface_type(
880 ifname, sys_class_net="/sys/class/net",
881 proc_net_vlan='/proc/net/vlan'):
882@@ -229,11 +261,11 @@
883 If the interface can be determined to be a non-Ethernet type, the type
884 that is found will be returned. (For example, 'loopback' or 'ipip'.)
885 """
886- sys_path = '%s/%s' % (sys_class_net, ifname)
887+ sys_path = os.path.join(sys_class_net, ifname)
888 if not os.path.isdir(sys_path):
889 return 'missing'
890
891- sys_type_path = '%s/type' % sys_path
892+ sys_type_path = os.path.join(sys_path, 'type')
893 with open(sys_type_path) as f:
894 iftype = int(f.read().strip())
895
896@@ -278,5 +310,32 @@
897 (if found) for each interface.
898 """
899 for name in interfaces:
900- interfaces[name]['type'] = get_interface_type(name)
901+ iface = interfaces[name]
902+ iftype = get_interface_type(name)
903+ interfaces[name]['type'] = iftype
904+ if iftype == 'ethernet.bond':
905+ iface['bonded_interfaces'] = get_bonded_interfaces(name)
906+ elif iftype == 'ethernet.vlan':
907+ iface['vid'] = get_vid_from_ifname(name)
908+ elif iftype == 'ethernet.bridge':
909+ iface['bridged_interfaces'] = get_bridged_interfaces(name)
910 return interfaces
911+
912+
913+def get_vid_from_ifname(ifname):
914+ """Returns the VID for the specified VLAN interface name.
915+
916+ Returns 0 if the VID could not be determined.
917+
918+ :return:int
919+ """
920+ vid = 0
921+ iface_vid_re = re.compile('.*\.([0-9]+)$')
922+ iface_vid_match = iface_vid_re.match(ifname)
923+ vlan_vid_re = re.compile('vlan([0-9]+)$')
924+ vlan_vid_match = vlan_vid_re.match(ifname)
925+ if iface_vid_match:
926+ vid = int(iface_vid_match.group(1))
927+ elif vlan_vid_match:
928+ vid = int(vlan_vid_match.group(1))
929+ return vid
930
931=== modified file 'src/provisioningserver/utils/tests/test_ipaddr.py'
932--- src/provisioningserver/utils/tests/test_ipaddr.py 2015-10-22 00:48:38 +0000
933+++ src/provisioningserver/utils/tests/test_ipaddr.py 2015-10-23 21:41:16 +0000
934@@ -22,12 +22,13 @@
935 from textwrap import dedent
936
937 from maastesting.testcase import MAASTestCase
938-from provisioningserver.network import filter_likely_unmanaged_networks
939+from provisioningserver.network import filter_and_annotate_networks
940 from provisioningserver.utils.ipaddr import (
941 _add_additional_interface_properties,
942 _get_settings_dict,
943 _parse_interface_definition,
944 annotate_with_driver_information,
945+ get_bonded_interfaces,
946 get_interface_type,
947 parse_ip_addr,
948 )
949@@ -328,7 +329,8 @@
950
951 def createInterfaceType(
952 self, ifname, iftype, is_bridge=False, is_vlan=False,
953- is_bond=False, is_wireless=False, is_physical=False):
954+ is_bond=False, is_wireless=False, is_physical=False,
955+ bonded_interfaces=None):
956 ifdir = os.path.join(self.tmp_sys_net, ifname)
957 os.mkdir(ifdir)
958 type_file = os.path.join(ifdir, 'type')
959@@ -342,6 +344,10 @@
960 f.close()
961 if is_bond:
962 os.mkdir(os.path.join(ifdir, 'bonding'))
963+ if bonded_interfaces is not None:
964+ f = open(os.path.join(ifdir, 'bonding', 'slaves'), 'w')
965+ f.write(b"%s\n" % ' '.join(bonded_interfaces).encode('utf-8'))
966+ f.close()
967 if is_physical or is_wireless:
968 os.mkdir(os.path.join(ifdir, 'device'))
969 os.mkdir(os.path.join(ifdir, 'device', 'driver'))
970@@ -384,6 +390,14 @@
971 Equals('ethernet.bond')
972 )
973
974+ def test__identifies_bonded_interfaces(self):
975+ self.createEthernetInterface(
976+ 'bond0', is_bond=True, bonded_interfaces=['eth0', 'eth1'])
977+ self.assertThat(get_bonded_interfaces(
978+ 'bond0', sys_class_net=self.tmp_sys_net),
979+ Equals(['eth0', 'eth1'])
980+ )
981+
982 def test__identifies_vlan_interface(self):
983 self.createEthernetInterface('vlan42', is_vlan=True)
984 self.assertThat(get_interface_type(
985@@ -452,7 +466,14 @@
986 interfaces = parse_ip_addr(ip_addr_output)
987 interfaces_with_types = annotate_with_driver_information(interfaces)
988 for name in interfaces:
989- self.assertThat(interfaces_with_types[name], Contains('type'))
990+ iface = interfaces_with_types[name]
991+ self.assertThat(iface, Contains('type'))
992+ if iface['type'] == 'ethernet.vlan':
993+ self.expectThat(iface, Contains('vid'))
994+ elif iface['type'] == 'ethernet.bond':
995+ self.expectThat(iface, Contains('bonded_interfaces'))
996+ elif iface['type'] == 'ethernet.bridge':
997+ self.expectThat(iface, Contains('bridged_interfaces'))
998
999
1000 class TestFilterLikelyUnmanagedNetworks(MAASTestCase):
1001@@ -467,7 +488,7 @@
1002 {"interface": "wlan0"},
1003 {"interface": "avian0"},
1004 ]
1005- actual_networks = filter_likely_unmanaged_networks(input_networks)
1006+ actual_networks = filter_and_annotate_networks(input_networks)
1007 expected_networks = [
1008 {"interface": "em0"},
1009 {"interface": "eth0"},
1010@@ -476,7 +497,7 @@
1011 ]
1012 self.assertThat(actual_networks, Equals(expected_networks))
1013
1014- def test__filters_based_on_json_data_if_available(self):
1015+ def test__filters_and_annotates_based_on_json_data_if_available(self):
1016 input_networks = [
1017 {"interface": "em0"},
1018 {"interface": "eth0"},
1019@@ -484,21 +505,35 @@
1020 {"interface": "bond0"},
1021 {"interface": "avian0"},
1022 {"interface": "br0"},
1023+ {"interface": "br1"},
1024+ {"interface": "br2"},
1025+ {"interface": "br3"},
1026 {"interface": "wlan0"},
1027 ]
1028 # Wow, these are some poorly named interfaces.
1029 # Though I guess technically an avian carrier is a physical interface.
1030 input_json = {
1031 "avian0": {"type": "ethernet.physical"},
1032- "br0": {"type": "ethernet.vlan"},
1033- "wlan0": {"type": "ethernet.bond"}
1034+ "br0": {"type": "ethernet.vlan", "vid": 123, "parent": "br3"},
1035+ "br1": {"type": "ethernet.bridge",
1036+ "bridged_interfaces": ["eth1", "eth2"]},
1037+ "br2": {"type": "ethernet.bridge",
1038+ "bridged_interfaces": ["avian0"]},
1039+ "br3": {"type": "ethernet.bridge"},
1040+ "wlan0": {"type": "ethernet.bond",
1041+ "bonded_interfaces": ["em0", "eth0"]}
1042 }
1043- actual_networks = filter_likely_unmanaged_networks(
1044+ actual_networks = filter_and_annotate_networks(
1045 input_networks, json.dumps(input_json))
1046 expected_networks = [
1047- {"interface": "avian0"},
1048- {"interface": "br0"},
1049- {"interface": "wlan0"},
1050+ {"interface": "avian0", 'type': "ethernet.physical"},
1051+ {"interface": "br0", 'type': "ethernet.vlan", "vid": 123,
1052+ "parent": "br3"},
1053+ {"interface": "br2", 'type': "ethernet.bridge",
1054+ "bridged_interfaces": "avian0"},
1055+ {"interface": "br3", 'type': "ethernet.bridge"},
1056+ {"interface": "wlan0", 'type': "ethernet.bond",
1057+ "bonded_interfaces": "em0 eth0"},
1058 ]
1059 self.assertThat(actual_networks, Equals(expected_networks))
1060
1061@@ -514,7 +549,7 @@
1062 ]
1063 input_json = {
1064 }
1065- actual_networks = filter_likely_unmanaged_networks(
1066+ actual_networks = filter_and_annotate_networks(
1067 input_networks, json.dumps(input_json))
1068 expected_networks = [
1069 {"interface": "em0"},