Merge lp:~mpontillo/maas/api-fix--link-up-with-subnet--bug-1659607--trunk into lp:~maas-committers/maas/trunk
- api-fix--link-up-with-subnet--bug-1659607--trunk
- Merge into trunk
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 |
Related bugs: |
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:
MAAS Lander (maas-lander) wrote : | # |
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://
Hit:2 http://
Get:3 http://
Hit:4 http://
Get:5 http://
Get:6 http://
Get:7 http://
Get:8 http://
Get:9 http://
Fetched 1,322 kB in 0s (2,707 kB/s)
Reading package lists...
sudo DEBIAN_
--no-
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~
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubun
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
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): |
lgtm!