Merge lp:~mpontillo/maas/api-fix--link-up-with-subnet--bug-1659607--trunk 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: 5676
Proposed branch: lp:~mpontillo/maas/api-fix--link-up-with-subnet--bug-1659607--trunk
Merge into: lp:~maas-committers/maas/trunk
Prerequisite: lp:~blake-rouse/maas/fix-pod-migration
Diff against target: 442 lines (+303/-9)
5 files modified
src/maasserver/api/interfaces.py (+37/-2)
src/maasserver/api/tests/test_interfaces.py (+224/-0)
src/maasserver/fields.py (+11/-2)
src/maasserver/forms_interface_link.py (+29/-4)
src/maasserver/tests/test_forms_interface_link.py (+2/-1)
To merge this branch: bzr merge lp:~mpontillo/maas/api-fix--link-up-with-subnet--bug-1659607--trunk
Reviewer Review Type Date Requested Status
Andres Rodriguez (community) Approve
Review via email: mp+315721@code.launchpad.net

Commit message

API cleanup for `interface link-subnet`.

 * Fix bug that disallowed changing a LINK_UP link to a LINK_UP link with a different subnet on the same VLAN (or no subnet).
 * Allow a subnet that seems invalid or a LINK_UP mode to be forcibly set via the API. (new force=True parameter)
 * Allow an interface to be explicitly disconnected. (Removes all IP addresses, and unsets its VLAN.)

Description of the change

See examples of new allowed usage here:

    https://paste.ubuntu.com/23870718/

To post a comment you must log in.
Revision history for this message
Andres Rodriguez (andreserl) wrote :

lgtm!

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

The attempt to merge lp:~mpontillo/maas/api-fix--link-up-with-subnet--bug-1659607--trunk into lp:maas failed. Below is the output from the failed tests.

Get:1 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Hit:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/universe Sources [121 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/main amd64 Packages [459 kB]
Get:7 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/universe amd64 Packages [387 kB]
Get:8 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/universe Translation-en [143 kB]
Get:9 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/multiverse amd64 Packages [8,080 B]
Fetched 1,322 kB in 0s (2,707 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newes...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/interfaces.py'
2--- src/maasserver/api/interfaces.py 2016-12-09 15:16:44 +0000
3+++ src/maasserver/api/interfaces.py 2017-01-27 19:44:54 +0000
4@@ -4,11 +4,15 @@
5 """API handlers: `Interface`."""
6
7 from django.forms.utils import ErrorList
8+from formencode.validators import StringBool
9 from maasserver.api.support import (
10 operation,
11 OperationsHandler,
12 )
13-from maasserver.api.utils import get_mandatory_param
14+from maasserver.api.utils import (
15+ get_mandatory_param,
16+ get_optional_param,
17+)
18 from maasserver.enum import (
19 INTERFACE_LINK_TYPE,
20 INTERFACE_TYPE,
21@@ -556,6 +560,11 @@
22 :param ip_address: IP address for the interface in subnet. Only used
23 when mode is STATIC. If not provided an IP address from subnet
24 will be auto selected.
25+ :param force: If True, allows LINK_UP to be set on the interface
26+ even if other links already exist. Also allows the selection of any
27+ VLAN, even a VLAN MAAS does not believe the interface to currently
28+ be on. Using this option will cause all other links on the
29+ interface to be deleted. (Defaults to False.)
30 :param default_gateway: True sets the gateway IP address for the subnet
31 as the default gateway for the node this interface belongs to.
32 Option can only be used with the AUTO and STATIC modes.
33@@ -578,6 +587,8 @@
34
35 Returns 404 if the node or interface is not found.
36 """
37+ force = get_optional_param(
38+ request.POST, 'force', default=False, validator=StringBool)
39 interface = Interface.objects.get_interface_or_404(
40 system_id, id, request.user, NODE_PERMISSION.EDIT)
41 node = interface.get_node()
42@@ -597,13 +608,37 @@
43 # Devices can only be set in static IP mode.
44 allowed_modes = [INTERFACE_LINK_TYPE.STATIC]
45 form = InterfaceLinkForm(
46- instance=interface, data=request.data, allowed_modes=allowed_modes)
47+ instance=interface, data=request.data, allowed_modes=allowed_modes,
48+ force=force)
49 if form.is_valid():
50 return form.save()
51 else:
52 raise MAASAPIValidationError(form.errors)
53
54 @operation(idempotent=False)
55+ def disconnect(self, request, system_id, id):
56+ """Disconnect an interface.
57+
58+ Deletes any linked subnets and IP addresses, and disconnects the
59+ interface from any associated VLAN.
60+
61+ Returns 404 if the node or interface is not found.
62+ """
63+ interface = Interface.objects.get_interface_or_404(
64+ system_id, id, request.user, NODE_PERMISSION.EDIT)
65+ node = interface.get_node()
66+ raise_error_if_controller(node, "disconnect")
67+ if node.node_type == NODE_TYPE.MACHINE:
68+ # This node needs to be in the correct state to modify
69+ # the interface.
70+ raise_error_for_invalid_state_on_allocated_operations(
71+ node, request.user, "disconnect")
72+ interface.ip_addresses.all().delete()
73+ interface.vlan = None
74+ interface.save()
75+ return interface
76+
77+ @operation(idempotent=False)
78 def unlink_subnet(self, request, system_id, id):
79 """Unlink interface to a subnet.
80
81
82=== modified file 'src/maasserver/api/tests/test_interfaces.py'
83--- src/maasserver/api/tests/test_interfaces.py 2016-12-12 14:17:23 +0000
84+++ src/maasserver/api/tests/test_interfaces.py 2017-01-27 19:44:54 +0000
85@@ -31,6 +31,7 @@
86 MatchesDict,
87 MatchesListwise,
88 MatchesSetwise,
89+ Not,
90 )
91
92
93@@ -1093,6 +1094,186 @@
94 "mode": Equals(INTERFACE_LINK_TYPE.STATIC),
95 }))
96
97+ def test_link_subnet_allows_subnet_with_link_up(self):
98+ self.become_admin()
99+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
100+ interface = factory.make_Interface(
101+ INTERFACE_TYPE.PHYSICAL, node=node)
102+ subnet = factory.make_Subnet(vlan=interface.vlan)
103+ uri = get_interface_uri(interface)
104+ response = self.client.post(uri, {
105+ "op": "link_subnet",
106+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
107+ "subnet": subnet.id,
108+ })
109+ self.assertEqual(
110+ http.client.OK, response.status_code, response.content)
111+ parsed_response = json_load_bytes(response.content)
112+ self.assertThat(
113+ parsed_response["links"][0], ContainsDict({
114+ "mode": Equals(INTERFACE_LINK_TYPE.LINK_UP),
115+ }))
116+ self.assertThat(
117+ parsed_response["links"][0]['subnet']['id'], Equals(subnet.id))
118+
119+ def test_link_subnet_allows_link_up_subnet_to_be_cleared(self):
120+ self.become_admin()
121+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
122+ interface = factory.make_Interface(
123+ INTERFACE_TYPE.PHYSICAL, node=node)
124+ subnet = factory.make_Subnet(vlan=interface.vlan)
125+ uri = get_interface_uri(interface)
126+ self.client.post(uri, {
127+ "op": "link_subnet",
128+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
129+ "subnet": subnet.id,
130+ })
131+ response = self.client.post(uri, {
132+ "op": "link_subnet",
133+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
134+ })
135+ self.assertEqual(
136+ http.client.OK, response.status_code, response.content)
137+ parsed_response = json_load_bytes(response.content)
138+ self.assertThat(
139+ parsed_response["links"][0], ContainsDict({
140+ "mode": Equals(INTERFACE_LINK_TYPE.LINK_UP),
141+ }))
142+ self.assertThat(parsed_response["links"][0], Not(Contains("subnet")))
143+
144+ def test_link_subnet_allows_link_up_subnet_to_be_changed(self):
145+ self.become_admin()
146+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
147+ interface = factory.make_Interface(
148+ INTERFACE_TYPE.PHYSICAL, node=node)
149+ subnet = factory.make_Subnet(vlan=interface.vlan)
150+ subnet2 = factory.make_Subnet(vlan=interface.vlan)
151+ uri = get_interface_uri(interface)
152+ self.client.post(uri, {
153+ "op": "link_subnet",
154+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
155+ "subnet": subnet.id,
156+ })
157+ response = self.client.post(uri, {
158+ "op": "link_subnet",
159+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
160+ "subnet": subnet2.id,
161+ })
162+ self.assertEqual(
163+ http.client.OK, response.status_code, response.content)
164+ parsed_response = json_load_bytes(response.content)
165+ self.assertThat(
166+ parsed_response["links"][0], ContainsDict({
167+ "mode": Equals(INTERFACE_LINK_TYPE.LINK_UP),
168+ }))
169+ self.assertThat(
170+ parsed_response["links"][0]['subnet']['id'], Equals(subnet2.id))
171+
172+ def test_link_subnet_disallows_subnets_on_another_vlan(self):
173+ self.become_admin()
174+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
175+ interface = factory.make_Interface(
176+ INTERFACE_TYPE.PHYSICAL, node=node)
177+ vlan2 = factory.make_VLAN()
178+ subnet = factory.make_Subnet(vlan=interface.vlan)
179+ subnet2 = factory.make_Subnet(vlan=vlan2)
180+ uri = get_interface_uri(interface)
181+ self.client.post(uri, {
182+ "op": "link_subnet",
183+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
184+ "subnet": subnet.id,
185+ })
186+ response = self.client.post(uri, {
187+ "op": "link_subnet",
188+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
189+ "subnet": subnet2.id,
190+ })
191+ self.assertEqual(
192+ http.client.BAD_REQUEST, response.status_code, response.content)
193+
194+ def test_link_subnet_allows_link_up_subnet_to_be_forcibly_changed(self):
195+ self.become_admin()
196+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
197+ interface = factory.make_Interface(
198+ INTERFACE_TYPE.PHYSICAL, node=node)
199+ vlan2 = factory.make_VLAN()
200+ subnet = factory.make_Subnet(vlan=interface.vlan)
201+ subnet2 = factory.make_Subnet(vlan=vlan2)
202+ uri = get_interface_uri(interface)
203+ self.client.post(uri, {
204+ "op": "link_subnet",
205+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
206+ "subnet": subnet.id,
207+ })
208+ response = self.client.post(uri, {
209+ "op": "link_subnet",
210+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
211+ "force": "True",
212+ "subnet": subnet2.id,
213+ })
214+ self.assertEqual(
215+ http.client.OK, response.status_code, response.content)
216+ parsed_response = json_load_bytes(response.content)
217+ self.assertThat(
218+ parsed_response["links"][0], ContainsDict({
219+ "mode": Equals(INTERFACE_LINK_TYPE.LINK_UP),
220+ }))
221+ self.assertThat(
222+ parsed_response["links"][0]['subnet']['id'], Equals(subnet2.id))
223+
224+ def test_link_subnet_force_link_up_deletes_existing_links(self):
225+ self.become_admin()
226+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
227+ interface = factory.make_Interface(
228+ INTERFACE_TYPE.PHYSICAL, node=node)
229+ vlan2 = factory.make_VLAN()
230+ subnet = factory.make_Subnet(vlan=interface.vlan)
231+ subnet2 = factory.make_Subnet(vlan=vlan2)
232+ uri = get_interface_uri(interface)
233+ self.client.post(uri, {
234+ "op": "link_subnet",
235+ "mode": INTERFACE_LINK_TYPE.DHCP,
236+ "subnet": subnet.id,
237+ })
238+ response = self.client.post(uri, {
239+ "op": "link_subnet",
240+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
241+ "force": "True",
242+ "subnet": subnet2.id,
243+ })
244+ self.assertEqual(
245+ http.client.OK, response.status_code, response.content)
246+ parsed_response = json_load_bytes(response.content)
247+ self.assertThat(
248+ parsed_response["links"][0], ContainsDict({
249+ "mode": Equals(INTERFACE_LINK_TYPE.LINK_UP),
250+ }))
251+ self.assertThat(
252+ parsed_response["links"][0]['subnet']['id'], Equals(subnet2.id))
253+
254+ def test_link_subnet_without_force_link_up_returns_bad_request(self):
255+ self.become_admin()
256+ node = factory.make_Node(owner=self.user, status=NODE_STATUS.READY)
257+ interface = factory.make_Interface(
258+ INTERFACE_TYPE.PHYSICAL, node=node)
259+ vlan2 = factory.make_VLAN()
260+ subnet = factory.make_Subnet(vlan=interface.vlan)
261+ subnet2 = factory.make_Subnet(vlan=vlan2)
262+ uri = get_interface_uri(interface)
263+ self.client.post(uri, {
264+ "op": "link_subnet",
265+ "mode": INTERFACE_LINK_TYPE.DHCP,
266+ "subnet": subnet.id,
267+ })
268+ response = self.client.post(uri, {
269+ "op": "link_subnet",
270+ "mode": INTERFACE_LINK_TYPE.LINK_UP,
271+ "force": "False",
272+ "subnet": subnet2.id,
273+ })
274+ self.assertEqual(
275+ http.client.BAD_REQUEST, response.status_code, response.content)
276+
277 def test_link_subnet_on_device_only_allows_static(self):
278 parent = factory.make_Node()
279 device = factory.make_Device(
280@@ -1229,6 +1410,49 @@
281 self.assertEqual(
282 http.client.CONFLICT, response.status_code, response.content)
283
284+ def test_disconnect_deletes_links_and_clears_vlan(self):
285+ # The form that is used is fully tested in test_forms_interface_link.
286+ # This just tests that the form is saved and the updated interface
287+ # is returned.
288+ self.become_admin()
289+ for status in (NODE_STATUS.READY, NODE_STATUS.BROKEN):
290+ node = factory.make_Node(interface=True, status=status)
291+ interface = node.get_boot_interface()
292+ subnet = factory.make_Subnet()
293+ dhcp_ip = factory.make_StaticIPAddress(
294+ alloc_type=IPADDRESS_TYPE.DHCP, ip="",
295+ subnet=subnet, interface=interface)
296+ uri = get_interface_uri(interface)
297+ response = self.client.post(uri, {
298+ "op": "disconnect",
299+ })
300+ self.assertEqual(
301+ http.client.OK, response.status_code, response.content)
302+ self.assertIsNone(reload_object(dhcp_ip))
303+ self.assertIsNone(reload_object(interface).vlan)
304+
305+ def test_disconnect_requries_admin(self):
306+ node = factory.make_Node(interface=True)
307+ interface = node.get_boot_interface()
308+ uri = get_interface_uri(interface)
309+ response = self.client.post(uri, {
310+ "op": "disconnect",
311+ })
312+ self.assertEqual(
313+ http.client.FORBIDDEN, response.status_code, response.content)
314+
315+ def test_disconnect_409_when_not_ready_or_broken(self):
316+ self.become_admin()
317+ for status in STATUSES:
318+ node = factory.make_Node(interface=True, status=status)
319+ interface = node.get_boot_interface()
320+ uri = get_interface_uri(interface)
321+ response = self.client.post(uri, {
322+ "op": "disconnect",
323+ })
324+ self.assertEqual(
325+ http.client.CONFLICT, response.status_code, response.content)
326+
327 def test_set_default_gateway_sets_gateway_link_ipv4_on_node(self):
328 # The form that is used is fully tested in test_forms_interface_link.
329 # This just tests that the form is saved and the node link is created.
330
331=== modified file 'src/maasserver/fields.py'
332--- src/maasserver/fields.py 2016-10-12 15:26:17 +0000
333+++ src/maasserver/fields.py 2017-01-27 19:44:54 +0000
334@@ -27,7 +27,10 @@
335 import re
336
337 from django import forms
338-from django.core.exceptions import ValidationError
339+from django.core.exceptions import (
340+ ObjectDoesNotExist,
341+ ValidationError,
342+)
343 from django.core.validators import (
344 RegexValidator,
345 URLValidator,
346@@ -662,7 +665,13 @@
347 if obj is not None:
348 return obj
349 else:
350- return self.queryset.get(id=object_id)
351+ try:
352+ return self.queryset.get(id=object_id)
353+ except ObjectDoesNotExist:
354+ # Re-raising this as a ValidationError prevents the API
355+ # from returning an internal server error rather than
356+ # a bad request.
357+ raise ValidationError("None found with id=%s." % value)
358 raise e
359
360
361
362=== modified file 'src/maasserver/forms_interface_link.py'
363--- src/maasserver/forms_interface_link.py 2016-12-07 12:46:14 +0000
364+++ src/maasserver/forms_interface_link.py 2017-01-27 19:44:54 +0000
365@@ -58,6 +58,7 @@
366 INTERFACE_LINK_TYPE.STATIC,
367 INTERFACE_LINK_TYPE.LINK_UP,
368 ])
369+ self.force = kwargs.pop("force", False)
370 mode_choices = [
371 (key, value)
372 for key, value in INTERFACE_LINK_TYPE_CHOICES
373@@ -74,7 +75,7 @@
374 'invalid_choice': compose_invalid_choice_text(
375 'mode', mode_choices),
376 })
377- if self.instance.vlan is None:
378+ if self.instance.vlan is None or self.force is True:
379 self.fields['subnet'].queryset = Subnet.objects.all()
380 else:
381 self.fields['subnet'].queryset = (
382@@ -142,12 +143,21 @@
383
384 def _clean_mode_link_up(self):
385 # Cannot set LINK_UP unless no other IP address are attached to
386- # this interface.
387- if self.instance.ip_addresses.count() > 0:
388+ # this interface. But exclude STICKY addresses where the IP address is
389+ # null, because the user could be trying to change the subnet for a
390+ # LINK_UP address. And exclude DISCOVERED because what MAAS discovered
391+ # doesn't matter with regard to the user's intention.
392+ exclude_types = (
393+ IPADDRESS_TYPE.STICKY, IPADDRESS_TYPE.DISCOVERED
394+ )
395+ has_active_links = self.instance.ip_addresses.exclude(
396+ alloc_type__in=exclude_types, ip__isnull=True).count() > 0
397+ if has_active_links and self.force is not True:
398 set_form_error(
399 self, "mode",
400 "Cannot configure interface to link up (with no IP address) "
401- "while other links are already configured.")
402+ "while other links are already configured. Specify force=True "
403+ "to override this behavior and delete all links.")
404
405 def _clean_default_gateway(self, cleaned_data):
406 mode = cleaned_data.get("mode", None)
407@@ -174,6 +184,21 @@
408 subnet = self.cleaned_data.get("subnet", None)
409 ip_address = self.cleaned_data.get("ip_address", None)
410 default_gateway = self.cleaned_data.get("default_gateway", False)
411+ # If force=True, allow the user to select any subnet (this will
412+ # implicitly change the VLAN).
413+ if mode == INTERFACE_LINK_TYPE.LINK_UP or self.force is True:
414+ # We're either setting the LINK_UP to a new subnet, or we're
415+ # forcing the issue.
416+ self.instance.clear_all_links(clearing_config=True)
417+ # If the user wants to force a particular subnet to be linked, clear
418+ # out the VLAN so that link_subnet() will reset the interface's subnet
419+ # to be correct.
420+ should_clear_vlan = (
421+ self.force is True and subnet is not None and
422+ self.instance.vlan is not None
423+ )
424+ if should_clear_vlan:
425+ self.instance.vlan = None
426 if not ip_address:
427 ip_address = None
428 link_ip = self.instance.link_subnet(
429
430=== modified file 'src/maasserver/tests/test_forms_interface_link.py'
431--- src/maasserver/tests/test_forms_interface_link.py 2016-12-12 14:17:23 +0000
432+++ src/maasserver/tests/test_forms_interface_link.py 2017-01-27 19:44:54 +0000
433@@ -382,7 +382,8 @@
434 self.assertEqual({
435 "mode": [
436 "Cannot configure interface to link up (with no IP address) "
437- "while other links are already configured."]
438+ "while other links are already configured. Specify force=True "
439+ "to override this behavior and delete all links."]
440 }, form.errors)
441
442 def test__LINK_UP_creates_link_STICKY_with_subnet(self):