Merge lp:~julian-edwards/maas/cluster-interfaces-as-networks into lp:~maas-committers/maas/trunk

Proposed by Julian Edwards
Status: Merged
Approved by: Julian Edwards
Approved revision: no longer in the source branch.
Merged at revision: 2672
Proposed branch: lp:~julian-edwards/maas/cluster-interfaces-as-networks
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 356 lines (+180/-21)
4 files modified
src/maasserver/forms.py (+32/-0)
src/maasserver/models/network.py (+16/-0)
src/maasserver/models/tests/test_network.py (+39/-0)
src/maasserver/tests/test_forms.py (+93/-21)
To merge this branch: bzr merge lp:~julian-edwards/maas/cluster-interfaces-as-networks
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+230038@code.launchpad.net

Commit message

Copy any network information from cluster interface edits into the Network table itself.

Description of the change

Pre-imped with jtv.

First part of a few branches to make a change that will list on which network each Node's MAC resides.

This simply copies network info from a cluster into the Network model itself, creating a new model as necessary, whose name is <nodegroup name>-<interface name> and the vlan_tag set as appropriate where it was present in the interface name.

At some point, we can reference the network model itself and remove the duplicate fields on the cluster interface. (I've not done it here because it would be a large change involving complex form stuff that I need to get advice on first.)

Subsequent branches will add a data migration to populate networks based on the cluster interfaces, and a web UI and API change to show the networks next to the MACs.

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

There are some finicky details here. I left comments inline; some of them I think really need addressing before landing.

review: Approve
Revision history for this message
Julian Edwards (julian-edwards) wrote :
Download full text (3.5 KiB)

On Friday 08 Aug 2014 05:32:16 you wrote:
> Review: Approve
>
> There are some finicky details here. I left comments inline; some of them I
> think really need addressing before landing.

Finicky is good sometimes, especially with networking! Thank you for the
thoughtful review, as ever.

> > + def save(self, *args, **kwargs):
> Can you document very briefly what unusual thing this method does? Saves
> the reader having to go through the code.

Done, well spotted.

> > + try:
> > + network.save()
> > + except ValidationError:
> > + # It already exists, keep calm and carry on.
>
> It may already exist, or you may have hit a difference between cluster
> interface validation and network validation, or there may be a bug which
> now leaves no trace. If there is no separate exception type for this, and
> you can't make an explicit call to just the uniqueness check, then at least
> log the error.

I've added the logging, thanks for calling this out.

> > + name = cluster_interface.interface
> > + nodegroup_name = cluster_interface.nodegroup.name
> > + vlan_tag = None
> > + if '.' in name:
> > + name, vlan_tag = name.split('.', 1)
>
> Won't this generate duplicate names for e.g. eth0 and eth0.1? The form's
> blanket amnesty for ValidationErrors might hide the problem, but AFAICT one
> of the networks would simply not appear.
>
> ISTM you need to replace the dot, not strip off the vlan suffix.

Well spotted! In fact, with some more stringent testing I added later, this
actually got caught.

> > +class TestMangleNameFromClusterInterface(MAASServerTestCase):
> The test case's name no longer matches that of the function it tests.

Argh. I renamed the damn function with the best of intentions as well!

> > + def test_returns_name_unaltered(self):
> Sometimes it does, yes. Could you make the condition under which this
> happens a bit more explicit, e.g. "straightforward name"?

Done. "Simple name".

> > + interface = sentinel.interface
> > + interface.nodegroup = sentinel.nodegroup
> > + interface.nodegroup.name = factory.make_name('name')
> > + interface.interface = 'eth0'
>
> Consider extracting these 4 lines (which recur in each of these tests) into
> a helper.

Done.

> > + def test_returns_with_colon_substituted(self):
> Maybe just "substitutes colon" instead of "returns with colon substituted"?

Done.

> All these tests use "eth0" as the base network interface. Not that that's
> particularly dangerous here, but it's a bad habit: some maniac could decide
> to take the base interface name from the operating system, or hard-code
> "eth0" for experimental changes, or assume that any colon or dot always
> comes after exactly 4 characters, without breaking the tests. There have
> to be at least some tests to show that you get the right value regardless
> of what that value actually is.

Right. I've changed the "simple" name test to use a random string for the
name.

> > +class TestNodeGroupInterfaceFormNetworkCreation(MAASServerTestCase):
> > + """Tests for when NodeGroupInterfaceForm creates a Network."""
>
> Given that lack of error detail ...

Read more...

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

The attempt to merge lp:~julian-edwards/maas/cluster-interfaces-as-networks into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Hit http://security.ubuntu.com trusty-security Release.gpg
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Hit http://security.ubuntu.com trusty-security Release
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:1 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Hit http://security.ubuntu.com trusty-security/main Sources
Get:2 http://nova.clouds.archive.ubuntu.com trusty-updates Release [59.7 kB]
Hit http://security.ubuntu.com trusty-security/universe Sources
Hit http://security.ubuntu.com trusty-security/main amd64 Packages
Hit http://security.ubuntu.com trusty-security/universe amd64 Packages
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
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:3 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [106 kB]
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [67.1 kB]
Get:5 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [285 kB]
Get:6 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [168 kB]
Get:7 http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en [125 kB]
Get:8 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en [82.7 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 894 kB in 0s (1,468 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 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 postgresql 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-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-netaddr python-netifaces python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi python-openssl python-paramiko python-pexpect python-pip python-pocket-lint python-psycopg2 python-pyinotify python-seamicroclient python-simplestreams p...

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 2014-08-04 16:18:21 +0000
3+++ src/maasserver/forms.py 2014-08-11 06:31:29 +0000
4@@ -111,6 +111,7 @@
5 Tag,
6 Zone,
7 )
8+from maasserver.models.network import get_name_and_vlan_from_cluster_interface
9 from maasserver.models.node import (
10 fqdn_is_duplicate,
11 nodegroup_fqdn,
12@@ -137,8 +138,11 @@
13 from metadataserver.fields import Bin
14 from metadataserver.models import CommissioningScript
15 from provisioningserver.drivers.osystem import OperatingSystemRegistry
16+from provisioningserver.logger import get_maas_logger
17 from provisioningserver.utils.network import make_network
18
19+maaslog = get_maas_logger()
20+
21 # A reusable null-option for choice fields.
22 BLANK_CHOICE = ('', '-------')
23
24@@ -1176,6 +1180,34 @@
25 'static_ip_range_high',
26 )
27
28+ def save(self, *args, **kwargs):
29+ """Override `ModelForm`.save() so that the network data is copied
30+ to a `Network` instance."""
31+ interface = super(NodeGroupInterfaceForm, self).save(*args, **kwargs)
32+ if interface.network is None:
33+ return interface
34+ name, vlan_tag = get_name_and_vlan_from_cluster_interface(interface)
35+ network = Network(
36+ name=name,
37+ ip=unicode(interface.network.network),
38+ netmask=unicode(interface.network.netmask),
39+ vlan_tag=vlan_tag,
40+ # I bloody hate the damn linter. It actually prefers this.
41+ description="Auto created when creating interface %s on "
42+ "cluster %s" % (
43+ interface.name, interface.nodegroup.name),
44+ )
45+ try:
46+ network.save()
47+ except ValidationError as e:
48+ # It probably already exists, keep calm and carry on.
49+ maaslog.warning(
50+ "Failed to create Network when adding/editing cluster "
51+ "interface %s with error [%s]. This is OK if it already "
52+ "exists, or it could be another error" % (name, unicode(e)))
53+ pass
54+ return interface
55+
56 def compute_name(self):
57 """Return the value the `name` field should have.
58
59
60=== modified file 'src/maasserver/models/network.py'
61--- src/maasserver/models/network.py 2014-07-08 07:33:43 +0000
62+++ src/maasserver/models/network.py 2014-08-11 06:31:29 +0000
63@@ -186,6 +186,22 @@
64 return specifier_class(spec)
65
66
67+def get_name_and_vlan_from_cluster_interface(cluster_interface):
68+ """Given a `NodeGroupInterface`, return a name suitable for a `Network`.
69+
70+ :return: a tuple of the new name and vlan tag. vlan tag may be None
71+ """
72+ name = cluster_interface.interface
73+ nodegroup_name = cluster_interface.nodegroup.name
74+ vlan_tag = None
75+ if '.' in name:
76+ _, vlan_tag = name.split('.', 1)
77+ name = name.replace('.', '-')
78+ name = name.replace(':', '-')
79+ network_name = "-".join((nodegroup_name, name))
80+ return network_name, vlan_tag
81+
82+
83 class NetworkManager(Manager):
84 """Manager for :class:`Network` model class.
85
86
87=== modified file 'src/maasserver/models/tests/test_network.py'
88--- src/maasserver/models/tests/test_network.py 2014-07-16 14:12:13 +0000
89+++ src/maasserver/models/tests/test_network.py 2014-08-11 06:31:29 +0000
90@@ -22,12 +22,14 @@
91 from maasserver.models.network import (
92 get_specifier_type,
93 IPSpecifier,
94+ get_name_and_vlan_from_cluster_interface,
95 NameSpecifier,
96 parse_network_spec,
97 VLANSpecifier,
98 )
99 from maasserver.testing.factory import factory
100 from maasserver.testing.testcase import MAASServerTestCase
101+from mock import sentinel
102 from netaddr import (
103 IPAddress,
104 IPNetwork,
105@@ -129,6 +131,43 @@
106 self.assertRaises(ValidationError, parse_network_spec, '10.4.4.4')
107
108
109+class TestGetNameAndVlanFromClusterInterface(MAASServerTestCase):
110+ """Tests for `get_name_and_vlan_from_cluster_interface`."""
111+
112+ def make_interface_name(self, basename):
113+ interface = sentinel.interface
114+ interface.nodegroup = sentinel.nodegroup
115+ interface.nodegroup.name = factory.make_name('name')
116+ interface.interface = basename
117+ return interface
118+
119+ def test_returns_simple_name_unaltered(self):
120+ interface = self.make_interface_name(factory.make_name('iface'))
121+ name, vlan_tag = get_name_and_vlan_from_cluster_interface(interface)
122+ expected_name = '%s-%s' % (
123+ interface.nodegroup.name, interface.interface)
124+ self.assertEqual((expected_name, None), (name, vlan_tag))
125+
126+ def test_substitutes_colon(self):
127+ interface = self.make_interface_name('eth0:0')
128+ name, vlan_tag = get_name_and_vlan_from_cluster_interface(interface)
129+ expected_name = '%s-eth0-0' % interface.nodegroup.name
130+ self.assertEqual((expected_name, None), (name, vlan_tag))
131+
132+ def test_returns_with_vlan_tag(self):
133+ interface = self.make_interface_name('eth0.5')
134+ name, vlan_tag = get_name_and_vlan_from_cluster_interface(interface)
135+ expected_name = '%s-eth0-5' % interface.nodegroup.name
136+ self.assertEqual((expected_name, '5'), (name, vlan_tag))
137+
138+ def test_returns_name_with_crazy_colon_and_vlan(self):
139+ # For truly twisted network admins.
140+ interface = self.make_interface_name('eth0:2.3')
141+ name, vlan_tag = get_name_and_vlan_from_cluster_interface(interface)
142+ expected_name = '%s-eth0-2-3' % interface.nodegroup.name
143+ self.assertEqual((expected_name, '3'), (name, vlan_tag))
144+
145+
146 class TestNetworkManager(MAASServerTestCase):
147
148 def test_get_from_spec_validates_first(self):
149
150=== modified file 'src/maasserver/tests/test_forms.py'
151--- src/maasserver/tests/test_forms.py 2014-08-08 19:04:18 +0000
152+++ src/maasserver/tests/test_forms.py 2014-08-11 06:31:29 +0000
153@@ -16,6 +16,7 @@
154
155 from cStringIO import StringIO
156 import json
157+import random
158
159 from django import forms
160 from django.conf import settings
161@@ -93,6 +94,7 @@
162 Zone,
163 )
164 from maasserver.models.config import DEFAULT_CONFIG
165+from maasserver.models.network import get_name_and_vlan_from_cluster_interface
166 from maasserver.models.staticipaddress import StaticIPAddress
167 from maasserver.node_action import (
168 Commission,
169@@ -1052,23 +1054,24 @@
170 ]
171
172
173+def make_ngi_instance(nodegroup=None):
174+ """Create a `NodeGroupInterface` with nothing set but `nodegroup`.
175+
176+ This is used by tests to instantiate the cluster interface form for
177+ a given cluster. We create an initial cluster interface object just
178+ to tell it which cluster that is.
179+ """
180+ if nodegroup is None:
181+ nodegroup = factory.make_node_group()
182+ return NodeGroupInterface(nodegroup=nodegroup)
183+
184+
185 class TestNodeGroupInterfaceForm(MAASServerTestCase):
186
187- def make_ngi_instance(self, nodegroup=None):
188- """Create a `NodeGroupInterface` with nothing set but `nodegroup`.
189-
190- This is used by tests to instantiate the cluster interface form for
191- a given cluster. We create an initial cluster interface object just
192- to tell it which cluster that is.
193- """
194- if nodegroup is None:
195- nodegroup = factory.make_node_group()
196- return NodeGroupInterface(nodegroup=nodegroup)
197-
198 def test__validates_parameters(self):
199 form = NodeGroupInterfaceForm(
200 data={'ip': factory.make_string()},
201- instance=self.make_ngi_instance())
202+ instance=make_ngi_instance())
203 self.assertFalse(form.is_valid())
204 self.assertEquals(
205 {'ip': ['Enter a valid IPv4 or IPv6 address.']}, form._errors)
206@@ -1079,7 +1082,7 @@
207 for field_name in nullable_fields:
208 del int_settings[field_name]
209 form = NodeGroupInterfaceForm(
210- data=int_settings, instance=self.make_ngi_instance())
211+ data=int_settings, instance=make_ngi_instance())
212 interface = form.save()
213 field_values = [
214 getattr(interface, field_name) for field_name in nullable_fields]
215@@ -1090,7 +1093,7 @@
216 int_settings = factory.get_interface_fields()
217 int_settings['name'] = name
218 form = NodeGroupInterfaceForm(
219- data=int_settings, instance=self.make_ngi_instance())
220+ data=int_settings, instance=make_ngi_instance())
221 interface = form.save()
222 self.assertEqual(name, interface.name)
223
224@@ -1099,7 +1102,7 @@
225 int_settings['interface'] = factory.make_name('ether')
226 del int_settings['name']
227 form = NodeGroupInterfaceForm(
228- data=int_settings, instance=self.make_ngi_instance())
229+ data=int_settings, instance=make_ngi_instance())
230 interface = form.save()
231 self.assertEqual(int_settings['interface'], interface.name)
232
233@@ -1108,7 +1111,7 @@
234 int_settings['interface'] = 'eth1+1'
235 del int_settings['name']
236 form = NodeGroupInterfaceForm(
237- data=int_settings, instance=self.make_ngi_instance())
238+ data=int_settings, instance=make_ngi_instance())
239 interface = form.save()
240 self.assertEqual('eth1--1', interface.name)
241
242@@ -1118,10 +1121,10 @@
243 del int_settings['name']
244 del int_settings['interface']
245 form1 = NodeGroupInterfaceForm(
246- data=int_settings, instance=self.make_ngi_instance())
247+ data=int_settings, instance=make_ngi_instance())
248 interface1 = form1.save()
249 form2 = NodeGroupInterfaceForm(
250- data=int_settings, instance=self.make_ngi_instance())
251+ data=int_settings, instance=make_ngi_instance())
252 interface2 = form2.save()
253 self.assertNotIn(interface1.name, [None, ''])
254 self.assertNotIn(interface2.name, [None, ''])
255@@ -1134,7 +1137,7 @@
256 del int_settings['name']
257 int_settings['interface'] = existing_interface.name
258 form = NodeGroupInterfaceForm(
259- data=int_settings, instance=self.make_ngi_instance(cluster))
260+ data=int_settings, instance=make_ngi_instance(cluster))
261 interface = form.save()
262 self.assertThat(interface.name, StartsWith(int_settings['interface']))
263 self.assertNotEqual(int_settings['interface'], interface.name)
264@@ -1165,7 +1168,7 @@
265 int_settings = factory.get_interface_fields(
266 management=NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS)
267 form = NodeGroupInterfaceForm(
268- data=int_settings, instance=self.make_ngi_instance())
269+ data=int_settings, instance=make_ngi_instance())
270 mock = self.patch(form, "get_duplicate_fqdns")
271 self.assertTrue(form.is_valid(), form.errors)
272 self.assertThat(mock, MockCalledOnceWith())
273@@ -1174,7 +1177,7 @@
274 int_settings = factory.get_interface_fields(
275 management=NODEGROUPINTERFACE_MANAGEMENT.DHCP_AND_DNS)
276 form = NodeGroupInterfaceForm(
277- data=int_settings, instance=self.make_ngi_instance())
278+ data=int_settings, instance=make_ngi_instance())
279 mock = self.patch(form, "get_duplicate_fqdns")
280 hostnames = [
281 factory.make_hostname("duplicate") for _ in range(0, 3)]
282@@ -1239,6 +1242,75 @@
283 self.assertEqual(expected_duplicates, duplicates)
284
285
286+class TestNodeGroupInterfaceFormNetworkCreation(MAASServerTestCase):
287+ """Tests for when NodeGroupInterfaceForm creates a Network."""
288+
289+ def test_creates_network_name(self):
290+ int_settings = factory.get_interface_fields()
291+ int_settings['interface'] = 'eth0:1'
292+ interface = make_ngi_instance()
293+ form = NodeGroupInterfaceForm(data=int_settings, instance=interface)
294+ form.save()
295+ [network] = Network.objects.all()
296+ expected, _ = get_name_and_vlan_from_cluster_interface(interface)
297+ self.assertEqual(expected, network.name)
298+
299+ def test_sets_vlan_tag(self):
300+ int_settings = factory.get_interface_fields()
301+ vlan_tag = random.randint(1, 10)
302+ int_settings['interface'] = 'eth0.%s' % vlan_tag
303+ interface = make_ngi_instance()
304+ form = NodeGroupInterfaceForm(data=int_settings, instance=interface)
305+ form.save()
306+ [network] = Network.objects.all()
307+ self.assertEqual(vlan_tag, network.vlan_tag)
308+
309+ def test_vlan_tag_is_None_if_no_vlan(self):
310+ int_settings = factory.get_interface_fields()
311+ int_settings['interface'] = 'eth0:1'
312+ interface = make_ngi_instance()
313+ form = NodeGroupInterfaceForm(data=int_settings, instance=interface)
314+ form.save()
315+ [network] = Network.objects.all()
316+ self.assertIs(None, network.vlan_tag)
317+
318+ def test_sets_network_values(self):
319+ int_settings = factory.get_interface_fields()
320+ interface = make_ngi_instance()
321+ form = NodeGroupInterfaceForm(data=int_settings, instance=interface)
322+ form.save()
323+ [network] = Network.objects.all()
324+ expected_net_address = unicode(interface.network.network)
325+ expected_netmask = unicode(interface.network.netmask)
326+ self.assertThat(
327+ network, MatchesStructure.byEquality(
328+ ip=expected_net_address,
329+ netmask=expected_netmask))
330+
331+ def test_does_not_create_new_network_if_already_exists(self):
332+ int_settings = factory.get_interface_fields()
333+ interface = make_ngi_instance()
334+ form = NodeGroupInterfaceForm(data=int_settings, instance=interface)
335+ # The easiest way to pre-create the same network is just to save
336+ # the form twice.
337+ form.save()
338+ [existing_network] = Network.objects.all()
339+ form.save()
340+ self.assertItemsEqual([existing_network], Network.objects.all())
341+
342+ def test_creates_many_unique_networks(self):
343+ names = ('eth0', 'eth0:1', 'eth0.1', 'eth0:1.2')
344+ for name in names:
345+ int_settings = factory.get_interface_fields()
346+ int_settings['interface'] = name
347+ interface = make_ngi_instance()
348+ form = NodeGroupInterfaceForm(
349+ data=int_settings, instance=interface)
350+ form.save()
351+
352+ self.assertEqual(len(names), len(Network.objects.all()))
353+
354+
355 class TestValidateNewStaticIPRanges(MAASServerTestCase):
356 """Tests for `validate_new_static_ip_ranges`()."""
357