Merge lp:~mpontillo/maas/make-ipaddresses-api-great-again--bug-1629061 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: 5472
Proposed branch: lp:~mpontillo/maas/make-ipaddresses-api-great-again--bug-1629061
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 978 lines (+519/-211)
8 files modified
src/maasserver/api/devices.py (+2/-15)
src/maasserver/api/ip_addresses.py (+153/-22)
src/maasserver/api/machines.py (+2/-15)
src/maasserver/api/rackcontrollers.py (+2/-15)
src/maasserver/api/regioncontrollers.py (+2/-15)
src/maasserver/api/tests/test_ipaddresses.py (+336/-129)
src/maasserver/models/staticipaddress.py (+5/-0)
src/maasserver/models/tests/test_staticipaddress.py (+17/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/make-ipaddresses-api-great-again--bug-1629061
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+308208@code.launchpad.net

Commit message

Make the 'ipaddresses' API do what everyone expected it to do.

 * Modify 'read' operation to show additional relevant fields, so that
   API users can determine which address they want to release.
   - Adds 'user' field so that administrators can verify that addresses
     they want to release belong to a particular user
   - Add 'alloc_type_name' field, so that administrators can verify the
     expected [human readable] type of the address.
   - Add 'interface_set' field, so that administrators can check which
     interfaces are linked to the each IP address.
 * Add 'user=<user>' option to 'read' operation, so that administrators
   can list addresses belonging to a specific user.
 * Add 'all=true' option to 'read' operation, so that administrators
   can list all addresses without restriction. (Normally, only
   "User reserved" addresses are shown. This option also removes the
   'user' filter.)
 * Add 'ip=<ip-address>' option to 'read' operation, so that
   administrators can verify that a specific address is the intended
   address to release before releasing it.
 * Allow the 'release' operation to release any IP address, not just
   a user-reserved IP address belonging to the current user.
   (Requires the 'force=true' parameter to be used, otherwise restricts
   the release operation the same way as before.)
 * Add 'ip=<ip-address>' optional argument to 'reserve' operation, so
   that the user experience is consistent across all operations on
   the 'ipaddresses' endpoint. This replaces the now-deprecated
   'ip_address' parameter, which may still be used, and acts as an alias
   to the 'ip' parameter (if the 'ip' parameter is not specified).
 * Drive-by DRY up for displayed interface fields in the API.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

A few things need fixing, like the "Undefined" default in allow_type_name, but it's a good change in all, especially for consumers of the API.

This could have been done in multiple branches. This *should* have been done in multiple branches. If not developed that way, at least split out for review. It is much easier to review small changes. This change is hardly egregious and it wasn't too hard to review, but it could have been better.

If nothing else know this: as a reviewer, when there are multiple branches to review, I will always review short branches first.

review: Needs Fixing
Revision history for this message
Gavin Panella (allenap) wrote :

The alloc_type_name thing is the only thing that shouldn't land, but as agreed you're going to change it to None or the empty string, so +1. That's not to say I think you should ignore my other comments :)

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Thanks for the feedback, Gavin. (I've changed `alloc_type_name` it to an empty string; will add a unit test for it as well.)

Small rant regarding multiple branches: I really wanted to propose this as multiple branches. Honest. ;-) I think my development style was getting in the way here. My process is, first I iterate on the API code and get the user experience to be how I want it. I'll iterate on changing the API code and doing end-to-end testing, periodically deploying to a live MAAS. By the time I was done with that, I had a ~400 line diff, and most of the existing tests were failing. At that time, I thought about splitting up the branch into a second --tests branch, but that didn't make sense because there was no way to land it by itself.

The key problem for me was, I didn't know where I was going with it until I was done. If I had a better plan in mind, doing 2 or 3 branches would have been easy.

Now, if we were using git, I might have used a "git rebase -i" to split out the branch into multiple, smaller feature branches. But that's a pain with bzr, and I wasn't sure if I was going to go back and change the code again after I completed the unit testing. So I left it how it was, and hoped I would't exceed our 800 line limit. Sorry. =(

In retrospect, it wouldn't have been too bad to do a branch for each API operation I changed. (though the test case refactoring I did might have gotten in the way of that.) But the return on investment isn't clear. If I spend an hour cleaning up the branch for easier review, how much time do I save you while reviewing it?

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Some replies below. Thanks for the review!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/devices.py'
2--- src/maasserver/api/devices.py 2016-05-12 19:07:37 +0000
3+++ src/maasserver/api/devices.py 2016-10-12 17:51:07 +0000
4@@ -6,6 +6,7 @@
5 "DevicesHandler",
6 ]
7
8+from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
9 from maasserver.api.logger import maaslog
10 from maasserver.api.nodes import (
11 NodeHandler,
12@@ -35,21 +36,7 @@
13 'tag_names',
14 'address_ttl',
15 'ip_addresses',
16- ('interface_set', (
17- 'id',
18- 'name',
19- 'type',
20- 'vlan',
21- 'mac_address',
22- 'parents',
23- 'children',
24- 'tags',
25- 'enabled',
26- 'links',
27- 'params',
28- 'discovered',
29- 'effective_mtu',
30- )),
31+ ('interface_set', DISPLAYED_INTERFACE_FIELDS),
32 'zone',
33 'node_type',
34 'node_type_name',
35
36=== modified file 'src/maasserver/api/ip_addresses.py'
37--- src/maasserver/api/ip_addresses.py 2016-07-30 01:17:54 +0000
38+++ src/maasserver/api/ip_addresses.py 2016-10-12 17:51:07 +0000
39@@ -8,6 +8,12 @@
40 ]
41
42 from django.http import Http404
43+from django.http.response import (
44+ HttpResponseBadRequest,
45+ HttpResponseForbidden,
46+)
47+from formencode.validators import StringBool
48+from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
49 from maasserver.api.support import (
50 operation,
51 OperationsHandler,
52@@ -39,6 +45,9 @@
53 Subnet,
54 )
55 from maasserver.utils.orm import transactional
56+from netaddr import IPAddress
57+from netaddr.core import AddrFormatError
58+from piston3.utils import rc
59 from provisioningserver.logger import get_maas_logger
60
61
62@@ -49,10 +58,22 @@
63 """Manage IP addresses allocated by MAAS."""
64 api_doc_section_name = "IP Addresses"
65 model = StaticIPAddress
66- fields = ('alloc_type', 'created', 'ip', 'subnet')
67+ fields = (
68+ 'alloc_type',
69+ 'alloc_type_name',
70+ 'created',
71+ 'ip',
72+ 'owner',
73+ ('interface_set', DISPLAYED_INTERFACE_FIELDS),
74+ 'subnet'
75+ )
76 create = update = delete = None
77
78 @classmethod
79+ def owner(cls, static_ip_address):
80+ return static_ip_address.user
81+
82+ @classmethod
83 def resource_uri(cls, *args, **kwargs):
84 return ('ipaddresses_handler', [])
85
86@@ -144,8 +165,10 @@
87
88 :param subnet: CIDR representation of the subnet on which the IP
89 reservation is required. e.g. 10.1.2.0/24
90- :param ip_address: The IP address, which must be within
91+ :param ip: The IP address, which must be within
92 a known subnet.
93+ :param ip_address: (Deprecated.) Alias for 'ip' parameter. Provided
94+ for backward compatibility.
95 :param hostname: The hostname to use for the specified IP address. If
96 no domain component is given, the default domain will be used.
97 :param mac: The MAC address that should be linked to this reservation.
98@@ -156,33 +179,43 @@
99 Returns 503 if there are no more IP addresses available.
100 """
101 subnet = get_optional_param(request.POST, "subnet")
102- ip_address = get_optional_param(
103- request.POST, "ip_address")
104+ ip = get_optional_param(request.POST, "ip")
105+ ip_address = get_optional_param(request.POST, "ip_address")
106 hostname = get_optional_param(request.POST, "hostname")
107 mac_address = get_optional_param(request.POST, "mac")
108
109- form = ClaimIPForMACForm(request.POST)
110+ # Fix to make the API backward compatible, yet consistent with the
111+ # other APIs in this file.
112+ if ip is None and ip_address is not None:
113+ ip = ip_address
114+
115+ form = ClaimIPForMACForm(data={
116+ 'subnet': subnet,
117+ 'ip_address': ip,
118+ 'hostname': hostname,
119+ 'mac': mac_address,
120+ })
121 if not form.is_valid():
122 raise MAASAPIValidationError(form.errors)
123
124- if ip_address is not None:
125- subnet = Subnet.objects.get_best_subnet_for_ip(ip_address)
126+ if ip is not None:
127+ subnet = Subnet.objects.get_best_subnet_for_ip(ip)
128 if subnet is None:
129 raise MAASAPIBadRequest(
130- "No known subnet for IP %s." % ip_address)
131+ "No known subnet for IP address: %s." % ip)
132 elif subnet is not None:
133 try:
134 subnet = Subnet.objects.get_object_by_specifiers_or_raise(
135 subnet)
136 except Http404:
137 raise MAASAPIBadRequest(
138- "Unable to identify subnet %s." % subnet) from None
139+ "Unable to identify subnet: %s." % subnet) from None
140 else:
141 raise MAASAPIBadRequest(
142- "Must supply either a subnet or a ip_address.")
143+ "Must supply either the 'subnet' or the 'ip' parameter.")
144
145 return self._claim_ip(
146- request.user, subnet, ip_address, mac_address,
147+ request.user, subnet, ip, mac_address,
148 hostname=hostname)
149
150 @operation(idempotent=False)
151@@ -192,21 +225,48 @@
152 :param ip: The IP address to release.
153 :type ip: unicode
154
155+ :param force: If True, allows a MAAS administrator to force an IP
156+ address to be released, even if it is not a user-reserved IP
157+ address or does not belong to the requesting user. Use with
158+ caution.
159+ :type force: bool
160+
161 Returns 404 if the provided IP address is not found.
162 """
163 ip = get_mandatory_param(request.POST, "ip")
164+ force = get_optional_param(
165+ request.POST, 'force', default=False, validator=StringBool)
166+
167+ if force is True and not request.user.is_superuser:
168+ return HttpResponseForbidden(
169+ content_type='text/plain',
170+ content="Force-releasing an IP address requires admin "
171+ "privileges.")
172+
173 form = ReleaseIPForm(request.POST)
174 if not form.is_valid():
175 raise MAASAPIValidationError(form.errors)
176
177+ if force:
178+ query_args = dict(ip=ip)
179+ else:
180+ query_args = dict(
181+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, ip=ip,
182+ user=request.user)
183+
184 # Get the reserved IP address, or raise bad request.
185 try:
186- ip_address = StaticIPAddress.objects.get(
187- alloc_type=IPADDRESS_TYPE.USER_RESERVED, ip=ip,
188- user=request.user)
189+ ip_address = StaticIPAddress.objects.get(**query_args)
190 except StaticIPAddress.DoesNotExist:
191- raise MAASAPIBadRequest(
192- "User reserved IP %s does not exist." % ip) from None
193+ if force:
194+ error = "IP address %s does not exist."
195+ else:
196+ error = (
197+ "IP address %s does not exist, is not a user-reserved "
198+ "address, or does not belong to the requesting user.\n"
199+ "If you are sure you want to release this address, use "
200+ "force=true as a MAAS administrator.")
201+ raise MAASAPIBadRequest(error % ip) from None
202
203 # Unlink the IP address from the interfaces it is connected.
204 interfaces = list(ip_address.interface_set.all())
205@@ -222,12 +282,83 @@
206 if interface.node is None and interface.only_has_link_up():
207 interface.delete()
208
209- maaslog.info("User %s released IP %s", request.user.username, ip)
210+ maaslog.info(
211+ "User %s%s released IP address: %s (%s).", request.user.username,
212+ " forcibly" if force else "", ip,
213+ ip_address.alloc_type_name)
214+
215+ return rc.DELETED
216
217 def read(self, request):
218- """List IPAddresses.
219-
220- Get a listing of all IPAddresses allocated to the requesting user.
221+ """List IP addresses known to MAAS.
222+
223+ By default, gets a listing of all IP addresses allocated to the
224+ requesting user.
225+
226+ :param ip: If specified, will only display information for the
227+ specified IP address.
228+ :type ip: unicode (must be an IPv4 or IPv6 address)
229+
230+ If the requesting user is a MAAS administrator, the following options
231+ may also be supplied:
232+
233+ :param all: If True, all reserved IP addresses will be shown. (By
234+ default, only addresses of type 'User reserved' that are assigned
235+ to the requesting user are shown.)
236+ :type all: bool
237+
238+ :param owner: If specified, filters the list to show only IP addresses
239+ owned by the specified username.
240+ :type user: unicode
241 """
242- return StaticIPAddress.objects.filter(
243- user=request.user).order_by('id')
244+ _all = get_optional_param(
245+ request.GET, 'all', default=False, validator=StringBool)
246+ ip = get_optional_param(request.GET, 'ip', default=None)
247+ owner = get_optional_param(request.GET, 'owner', default=None)
248+ # If an IP address was specified, validate that it is indeed a valid
249+ # IP address before trying to hit the database with it.
250+ if ip is not None:
251+ try:
252+ IPAddress(ip)
253+ except AddrFormatError:
254+ return HttpResponseBadRequest(
255+ content_type='text/plain',
256+ content="Parameter 'ip' must contain an IP address.")
257+ # It doesn't make sense for this API to display NULL IP addresses.
258+ # These indicate things like "do DHCP on <interface>", "we observed a
259+ # commissioned node connected to <subnet>", and "we would assign an
260+ # automatic address to <machine-interface>, but it isn't deployed at
261+ # the moment".
262+ query = StaticIPAddress.objects.exclude(ip__isnull=True)
263+ if _all and not request.user.is_superuser:
264+ return HttpResponseForbidden(
265+ content_type='text/plain',
266+ content="Listing all IP addresses requires admin "
267+ "privileges.")
268+ if owner is not None and not request.user.is_superuser:
269+ return HttpResponseForbidden(
270+ content_type='text/plain',
271+ content="Listing another user's IP addresses requires admin "
272+ "privileges.")
273+ # Add additional filters based on permissions, and based on the
274+ # request parameters.
275+ if not request.user.is_superuser:
276+ # If the requesting user isn't an admin, always filter by the
277+ # currently-logged-in API user.
278+ query = query.filter(user=request.user)
279+ elif owner is not None:
280+ # If the admin specified a username filter, use that.
281+ query = query.filter(user__username=owner)
282+ elif _all is False:
283+ # If the admin didn't specify 'all=true' (and didn't specify a
284+ # specific username), only show IP addresses belonging to the
285+ # admin. (This preserves API compatibility.)
286+ query = query.filter(user=request.user)
287+ # If we're not displaying all addresses, we also need to filter by
288+ # address type (again, to preserve API compatibility).
289+ if not _all:
290+ query = query.filter(alloc_type=IPADDRESS_TYPE.USER_RESERVED)
291+ if ip is not None:
292+ query = query.filter(ip=ip)
293+ query = query.order_by('id')
294+ return query
295
296=== modified file 'src/maasserver/api/machines.py'
297--- src/maasserver/api/machines.py 2016-09-29 19:23:13 +0000
298+++ src/maasserver/api/machines.py 2016-10-12 17:51:07 +0000
299@@ -24,6 +24,7 @@
300 StringBool,
301 )
302 from maasserver import locks
303+from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
304 from maasserver.api.logger import maaslog
305 from maasserver.api.nodes import (
306 AnonNodeHandler,
307@@ -111,21 +112,7 @@
308 'tag_names',
309 'address_ttl',
310 'ip_addresses',
311- ('interface_set', (
312- 'id',
313- 'name',
314- 'type',
315- 'vlan',
316- 'mac_address',
317- 'parents',
318- 'children',
319- 'tags',
320- 'enabled',
321- 'links',
322- 'params',
323- 'discovered',
324- 'effective_mtu',
325- )),
326+ ('interface_set', DISPLAYED_INTERFACE_FIELDS),
327 'zone',
328 'disable_ipv4',
329 'constraints_by_type',
330
331=== modified file 'src/maasserver/api/rackcontrollers.py'
332--- src/maasserver/api/rackcontrollers.py 2016-06-08 20:20:40 +0000
333+++ src/maasserver/api/rackcontrollers.py 2016-10-12 17:51:07 +0000
334@@ -9,6 +9,7 @@
335
336 from django.conf import settings
337 from django.http import HttpResponse
338+from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
339 from maasserver.api.nodes import (
340 NodeHandler,
341 NodesHandler,
342@@ -43,21 +44,7 @@
343 'power_type',
344 'power_state',
345 'ip_addresses',
346- ('interface_set', (
347- 'id',
348- 'name',
349- 'type',
350- 'vlan',
351- 'mac_address',
352- 'parents',
353- 'children',
354- 'tags',
355- 'enabled',
356- 'links',
357- 'params',
358- 'discovered',
359- 'effective_mtu',
360- )),
361+ ('interface_set', DISPLAYED_INTERFACE_FIELDS),
362 'zone',
363 'status_action',
364 'node_type',
365
366=== modified file 'src/maasserver/api/regioncontrollers.py'
367--- src/maasserver/api/regioncontrollers.py 2016-04-28 01:14:14 +0000
368+++ src/maasserver/api/regioncontrollers.py 2016-10-12 17:51:07 +0000
369@@ -6,6 +6,7 @@
370 'RegionControllersHandler',
371 ]
372
373+from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS
374 from maasserver.api.nodes import (
375 NodeHandler,
376 NodesHandler,
377@@ -31,21 +32,7 @@
378 'power_type',
379 'power_state',
380 'ip_addresses',
381- ('interface_set', (
382- 'id',
383- 'name',
384- 'type',
385- 'vlan',
386- 'mac_address',
387- 'parents',
388- 'children',
389- 'tags',
390- 'enabled',
391- 'links',
392- 'params',
393- 'discovered',
394- 'effective_mtu',
395- )),
396+ ('interface_set', DISPLAYED_INTERFACE_FIELDS),
397 'zone',
398 'status_action',
399 'node_type',
400
401=== modified file 'src/maasserver/api/tests/test_ipaddresses.py'
402--- src/maasserver/api/tests/test_ipaddresses.py 2016-07-30 01:17:54 +0000
403+++ src/maasserver/api/tests/test_ipaddresses.py 2016-10-12 17:51:07 +0000
404@@ -2,6 +2,8 @@
405 # GNU Affero General Public License version 3 (see the file LICENSE).
406
407 """Tests for IP addresses API."""
408+from testtools.matchers import HasLength
409+
410
411 __all__ = []
412
413@@ -22,20 +24,342 @@
414 from maasserver.testing.factory import factory
415 from maasserver.utils.converters import json_load_bytes
416 from maasserver.utils.orm import reload_object
417+from maastesting.matchers import DocTestMatches
418 from netaddr import IPAddress
419 from testtools.matchers import Equals
420
421
422-class TestIPAddressesAPI(APITestCase.ForUser):
423+class TestIPAddressesAPI(APITestCase.ForUserAndAdmin):
424+
425+ def test_handler_path(self):
426+ self.assertEqual(
427+ '/api/2.0/ipaddresses/', reverse('ipaddresses_handler'))
428+
429+ def test_GET_returns_ipaddresses(self):
430+ original_ipaddress = factory.make_StaticIPAddress(
431+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
432+ response = self.client.get(reverse('ipaddresses_handler'))
433+ self.assertEqual(
434+ http.client.OK, response.status_code, response.content)
435+
436+ parsed_result = json_load_bytes(response.content)
437+ self.assertEqual(1, len(parsed_result), response.content)
438+ [returned_address] = parsed_result
439+ fields = {'alloc_type', 'alloc_type_name', 'ip'}
440+ self.assertEqual(
441+ fields.union({
442+ 'resource_uri',
443+ 'created',
444+ 'subnet',
445+ 'interface_set',
446+ 'owner'}),
447+ set(returned_address.keys()))
448+ expected_values = {
449+ field: getattr(original_ipaddress, field)
450+ for field in fields
451+ if field not in ('resource_uri', 'created')
452+ }
453+ # Test that these exist, but not for exact values.
454+ del returned_address['created']
455+ del returned_address['subnet']
456+ del returned_address['owner']
457+ del returned_address['interface_set']
458+ expected_values['resource_uri'] = reverse('ipaddresses_handler')
459+ self.assertEqual(expected_values, returned_address)
460+
461+ def test_GET_returns_empty_if_no_ipaddresses(self):
462+ response = self.client.get(reverse('ipaddresses_handler'))
463+ self.assertEqual(
464+ http.client.OK, response.status_code, response.content)
465+ self.assertEqual([], json_load_bytes(response.content))
466+
467+ def test_GET_only_returns_request_users_addresses(self):
468+ ipaddress = factory.make_StaticIPAddress(
469+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
470+ factory.make_StaticIPAddress(
471+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=factory.make_User())
472+ response = self.client.get(reverse('ipaddresses_handler'))
473+ self.assertEqual(
474+ http.client.OK, response.status_code, response.content)
475+ parsed_result = json_load_bytes(response.content)
476+ [returned_address] = parsed_result
477+ self.assertEqual(ipaddress.ip, returned_address['ip'])
478+
479+ def test_GET_returns_all_addresses_if_admin_and_all_specified(self):
480+ factory.make_StaticIPAddress(
481+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
482+ factory.make_StaticIPAddress(
483+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=factory.make_User())
484+ response = self.client.get(reverse('ipaddresses_handler'), {
485+ "all": "1"
486+ })
487+ if self.user.is_superuser:
488+ self.assertEqual(
489+ http.client.OK, response.status_code, response.content)
490+ parsed_result = json_load_bytes(response.content)
491+ self.assertThat(parsed_result, HasLength(2))
492+ else:
493+ self.assertEqual(
494+ http.client.FORBIDDEN, response.status_code, response.content)
495+ self.assertThat(
496+ response.content.decode("utf-8"),
497+ Equals("Listing all IP addresses requires admin privileges."))
498+
499+ def test_GET_returns_other_user_addresses_if_admin_and_user_specified(
500+ self):
501+ factory.make_StaticIPAddress(
502+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
503+ user2 = factory.make_User()
504+ ipaddress2 = factory.make_StaticIPAddress(
505+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=user2)
506+ response = self.client.get(reverse('ipaddresses_handler'), {
507+ "owner": user2.username
508+ })
509+ if self.user.is_superuser:
510+ self.assertEqual(
511+ http.client.OK, response.status_code, response.content)
512+ parsed_result = json_load_bytes(response.content)
513+ self.assertThat(parsed_result, HasLength(1))
514+ self.assertEqual(ipaddress2.ip, parsed_result[0]['ip'])
515+ else:
516+ self.assertEqual(
517+ http.client.FORBIDDEN, response.status_code, response.content)
518+ self.assertThat(
519+ response.content.decode("utf-8"),
520+ Equals(
521+ "Listing another user's IP addresses requires admin "
522+ "privileges."))
523+
524+ def test_GET_returns_other_users_ip_address_for_admin_with_all_with_ip(
525+ self):
526+ factory.make_StaticIPAddress(
527+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
528+ user2 = factory.make_User()
529+ ipaddress2 = factory.make_StaticIPAddress(
530+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=user2)
531+ response = self.client.get(reverse('ipaddresses_handler'), {
532+ "ip": str(ipaddress2.ip),
533+ "all": "true"
534+ })
535+ if self.user.is_superuser:
536+ self.assertEqual(
537+ http.client.OK, response.status_code, response.content)
538+ parsed_result = json_load_bytes(response.content)
539+ self.assertThat(parsed_result, HasLength(1))
540+ self.assertEqual(ipaddress2.ip, parsed_result[0]['ip'])
541+ else:
542+ self.assertEqual(
543+ http.client.FORBIDDEN, response.status_code, response.content)
544+ self.assertThat(
545+ response.content.decode("utf-8"),
546+ Equals("Listing all IP addresses requires admin privileges."))
547+
548+ def test_GET_with_all_for_admin_returns_non_user_reserved_types(
549+ self):
550+ factory.make_StaticIPAddress(alloc_type=IPADDRESS_TYPE.STICKY)
551+ factory.make_StaticIPAddress(
552+ alloc_type=IPADDRESS_TYPE.USER_RESERVED)
553+ response = self.client.get(reverse('ipaddresses_handler'), {
554+ "all": "true"
555+ })
556+ if self.user.is_superuser:
557+ self.assertEqual(
558+ http.client.OK, response.status_code, response.content)
559+ parsed_result = json_load_bytes(response.content)
560+ self.assertThat(parsed_result, HasLength(2))
561+ else:
562+ self.assertEqual(
563+ http.client.FORBIDDEN, response.status_code, response.content)
564+ self.assertThat(
565+ response.content.decode("utf-8"),
566+ Equals("Listing all IP addresses requires admin privileges."))
567+
568+ def test_GET_returns_own_ip_address_with_ip(self):
569+ factory.make_StaticIPAddress(
570+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
571+ ipaddress2 = factory.make_StaticIPAddress(
572+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
573+ response = self.client.get(reverse('ipaddresses_handler'), {
574+ "ip": str(ipaddress2.ip),
575+ })
576+ self.assertEqual(
577+ http.client.OK, response.status_code, response.content)
578+ parsed_result = json_load_bytes(response.content)
579+ self.assertThat(parsed_result, HasLength(1))
580+ self.assertEqual(ipaddress2.ip, parsed_result[0]['ip'])
581+
582+ def test_GET_sorts_by_id(self):
583+ addrs = []
584+ for _ in range(3):
585+ addrs.append(
586+ factory.make_StaticIPAddress(
587+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user))
588+ response = self.client.get(reverse('ipaddresses_handler'))
589+ self.assertEqual(
590+ http.client.OK, response.status_code, response.content)
591+ parsed_result = json_load_bytes(response.content)
592+ expected = [
593+ addr.ip for addr in
594+ sorted(addrs, key=lambda addr: getattr(addr, "id"))]
595+ observed = [result['ip'] for result in parsed_result]
596+ self.assertEqual(expected, observed)
597+
598+
599+class TestIPAddressesReleaseAPI(APITestCase.ForUserAndAdmin):
600+
601+ scenarios = (
602+ ("normal", {"force": None}),
603+ ("without_force", {"force": False}),
604+ ("with_force", {"force": True}),
605+ )
606+
607+ @property
608+ def force_should_work(self):
609+ # The 'force' parameter should only work if (1) the user-under-test
610+ # is a superuser, and (2) force=true was specified.
611+ return self.user.is_superuser and self.force is True
612+
613+ @property
614+ def expect_forbidden(self):
615+ # The 'force' parameter should only work if (1) the user-under-test
616+ # is a superuser, and (2) force=true was specified.
617+ return not self.user.is_superuser and self.force is True
618+
619+ def expected_status(self, status):
620+ # Non-administrators always get a FORBIDDEN (403) when requesting
621+ # a forced delete.
622+ if self.force and not self.user.is_superuser:
623+ return http.client.FORBIDDEN
624+ else:
625+ return status
626+
627+ def post_release_request(self, ip, mac=None):
628+ params = {
629+ 'op': 'release',
630+ 'ip': ip,
631+ }
632+ if mac is not None:
633+ params["mac"] = mac
634+ if self.force is not None:
635+ params["force"] = str(self.force)
636+ return self.client.post(reverse('ipaddresses_handler'), params)
637+
638+ def test_POST_release_rejects_invalid_ip(self):
639+ response = self.post_release_request("1690.254.0.1")
640+ expected_status = self.expected_status(http.client.BAD_REQUEST)
641+ self.assertEqual(expected_status, response.status_code)
642+ if expected_status != http.client.FORBIDDEN:
643+ self.assertEqual(
644+ dict(ip=["Enter a valid IPv4 or IPv6 address."]),
645+ json_load_bytes(response.content))
646+ else:
647+ self.assertEqual(
648+ "Force-releasing an IP address requires admin privileges.",
649+ response.content.decode("utf-8"))
650+
651+ def test_POST_release_allows_admin_to_release_other_users_ip(self):
652+ factory.make_StaticIPAddress(
653+ user=factory.make_User(), alloc_type=IPADDRESS_TYPE.USER_RESERVED,
654+ ip="192.168.0.1")
655+ response = self.post_release_request("192.168.0.1")
656+ if self.expect_forbidden:
657+ self.assertEqual(http.client.FORBIDDEN, response.status_code)
658+ self.assertEqual(
659+ "Force-releasing an IP address requires admin privileges.",
660+ response.content.decode("utf-8"))
661+ elif not self.force:
662+ self.assertEqual(http.client.BAD_REQUEST, response.status_code)
663+ self.assertThat(
664+ response.content.decode("utf-8"),
665+ DocTestMatches("...does not belong to the requesting user..."))
666+ else:
667+ self.assertEqual(http.client.NO_CONTENT, response.status_code)
668+ self.assertThat(
669+ response.content.decode("utf-8"),
670+ Equals(""))
671+
672+ def test_POST_release_allows_admin_to_release_sticky_ip(self):
673+ # Make an "orphaned" IP address, like the one in bug #1630034.
674+ static_ip = factory.make_StaticIPAddress(
675+ user=factory.make_User(), alloc_type=IPADDRESS_TYPE.STICKY)
676+ response = self.post_release_request(str(static_ip.ip))
677+ if self.expect_forbidden:
678+ self.assertEqual(http.client.FORBIDDEN, response.status_code)
679+ self.assertEqual(
680+ "Force-releasing an IP address requires admin privileges.",
681+ response.content.decode("utf-8"))
682+ elif not self.force:
683+ self.assertEqual(http.client.BAD_REQUEST, response.status_code)
684+ self.assertThat(
685+ response.content.decode("utf-8"),
686+ DocTestMatches("...does not belong to the requesting user..."))
687+ else:
688+ self.assertEqual(http.client.NO_CONTENT, response.status_code)
689+ self.assertThat(
690+ response.content.decode("utf-8"),
691+ Equals(""))
692+
693+ def test_POST_release_deallocates_address(self):
694+ ipaddress = factory.make_StaticIPAddress(
695+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
696+ response = self.post_release_request(ipaddress.ip)
697+ self.assertEqual(
698+ self.expected_status(http.client.NO_CONTENT),
699+ response.status_code, response.content)
700+ if not self.expect_forbidden:
701+ self.assertIsNone(reload_object(ipaddress))
702+
703+ def test_POST_release_deletes_unknown_interface(self):
704+ subnet = factory.make_Subnet()
705+ unknown_nic = factory.make_Interface(INTERFACE_TYPE.UNKNOWN)
706+ ipaddress = unknown_nic.link_subnet(
707+ INTERFACE_LINK_TYPE.STATIC, subnet,
708+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
709+ response = self.post_release_request(ipaddress.ip)
710+ self.assertEqual(
711+ self.expected_status(http.client.NO_CONTENT),
712+ response.status_code, response.content)
713+ if not self.expect_forbidden:
714+ self.assertIsNone(reload_object(unknown_nic))
715+
716+ def test_POST_release_does_not_delete_interfaces_linked_to_nodes(self):
717+ node = factory.make_Node()
718+ attached_nic = factory.make_Interface(node=node)
719+ subnet = factory.make_Subnet()
720+ ipaddress = attached_nic.link_subnet(
721+ INTERFACE_LINK_TYPE.STATIC, subnet,
722+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
723+
724+ self.post_release_request(ipaddress.ip)
725+ self.assertEqual(attached_nic, reload_object(attached_nic))
726+
727+ def test_POST_release_does_not_delete_other_IPs_I_own(self):
728+ ipaddress = factory.make_StaticIPAddress(
729+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
730+ other_address = factory.make_StaticIPAddress(
731+ alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
732+ response = self.post_release_request(ipaddress.ip)
733+ self.assertEqual(
734+ self.expected_status(http.client.NO_CONTENT),
735+ response.status_code, response.content)
736+ self.assertIsNotNone(reload_object(other_address))
737+
738+
739+class TestIPAddressesReserveAPI(APITestCase.ForUser):
740+
741+ scenarios = (
742+ ("with_ip_param", {"ip_param": "ip"}),
743+ ("with_ip_address_param", {"ip_param": "ip_address"}),
744+ )
745
746 def post_reservation_request(
747- self, subnet=None, ip_address=None, network=None,
748- mac=None, hostname=None):
749+ self, subnet=None, ip_address=None, network=None, mac=None,
750+ hostname=None):
751 params = {
752 'op': 'reserve',
753 }
754 if ip_address is not None:
755- params["ip_address"] = ip_address
756+ params[self.ip_param] = ip_address
757 if subnet is not None:
758 params["subnet"] = subnet.cidr
759 if network is not None and subnet is None:
760@@ -46,40 +370,29 @@
761 params["hostname"] = hostname
762 return self.client.post(reverse('ipaddresses_handler'), params)
763
764- def post_release_request(self, ip, mac=None):
765- params = {
766- 'op': 'release',
767- 'ip': ip,
768- }
769- if mac is not None:
770- params["mac"] = mac
771- return self.client.post(reverse('ipaddresses_handler'), params)
772-
773 def assertNoMatchingNetworkError(self, response, net):
774 self.assertEqual(
775 http.client.BAD_REQUEST, response.status_code, response.content)
776 expected = (
777- "Unable to identify subnet %s." % str(net))
778+ "Unable to identify subnet: %s." % str(net))
779 self.assertEqual(
780 expected.encode(settings.DEFAULT_CHARSET),
781 response.content)
782
783- def test_handler_path(self):
784- self.assertEqual(
785- '/api/2.0/ipaddresses/', reverse('ipaddresses_handler'))
786-
787 def test_POST_reserve_creates_ipaddress(self):
788 subnet = factory.make_Subnet()
789 response = self.post_reservation_request(subnet)
790 self.assertEqual(http.client.OK, response.status_code)
791 returned_address = json_load_bytes(response.content)
792 [staticipaddress] = StaticIPAddress.objects.all()
793- # We don't need to test the value of the 'created' datetime
794- # field. By removing it, we also test for its presence.
795+ # Test that these fields exist, but don't test for exact values.
796 del returned_address['created']
797 del returned_address['subnet']
798+ del returned_address['interface_set']
799+ del returned_address['owner']
800 expected = dict(
801 alloc_type=staticipaddress.alloc_type,
802+ alloc_type_name=staticipaddress.alloc_type_name,
803 ip=staticipaddress.ip,
804 resource_uri=reverse('ipaddresses_handler'),
805 )
806@@ -102,8 +415,11 @@
807 # field. By removing it, we also test for its presence.
808 del returned_address['created']
809 del returned_address['subnet']
810+ del returned_address['interface_set']
811+ del returned_address['owner']
812 expected = dict(
813 alloc_type=staticipaddress.alloc_type,
814+ alloc_type_name=staticipaddress.alloc_type_name,
815 ip=staticipaddress.ip,
816 resource_uri=reverse('ipaddresses_handler'),
817 )
818@@ -281,112 +597,3 @@
819 self.assertEqual(
820 dict(ip_address=["Enter a valid IPv4 or IPv6 address."]),
821 json_load_bytes(response.content))
822-
823- def test_POST_release_rejects_invalid_ip(self):
824- response = self.post_release_request("1690.254.0.1")
825- self.assertEqual(http.client.BAD_REQUEST, response.status_code)
826- self.assertEqual(
827- dict(ip=["Enter a valid IPv4 or IPv6 address."]),
828- json_load_bytes(response.content))
829-
830- def test_GET_returns_ipaddresses(self):
831- original_ipaddress = factory.make_StaticIPAddress(
832- user=self.user)
833- response = self.client.get(reverse('ipaddresses_handler'))
834- self.assertEqual(
835- http.client.OK, response.status_code, response.content)
836-
837- parsed_result = json_load_bytes(response.content)
838- self.assertEqual(1, len(parsed_result), response.content)
839- [returned_address] = parsed_result
840- fields = {'alloc_type', 'ip'}
841- self.assertEqual(
842- fields.union({'resource_uri', 'created', 'subnet'}),
843- set(returned_address.keys()))
844- expected_values = {
845- field: getattr(original_ipaddress, field)
846- for field in fields
847- if field not in ('resource_uri', 'created')
848- }
849- # We don't need to test the value of the 'created' datetime
850- # field.
851- del returned_address['created']
852- del returned_address['subnet']
853- expected_values['resource_uri'] = reverse('ipaddresses_handler')
854- self.assertEqual(expected_values, returned_address)
855-
856- def test_GET_returns_empty_if_no_ipaddresses(self):
857- response = self.client.get(reverse('ipaddresses_handler'))
858- self.assertEqual(
859- http.client.OK, response.status_code, response.content)
860- self.assertEqual([], json_load_bytes(response.content))
861-
862- def test_GET_only_returns_request_users_addresses(self):
863- ipaddress = factory.make_StaticIPAddress(user=self.user)
864- factory.make_StaticIPAddress(user=factory.make_User())
865- response = self.client.get(reverse('ipaddresses_handler'))
866- self.assertEqual(
867- http.client.OK, response.status_code, response.content)
868- parsed_result = json_load_bytes(response.content)
869- [returned_address] = parsed_result
870- self.assertEqual(ipaddress.ip, returned_address['ip'])
871-
872- def test_GET_sorts_by_id(self):
873- addrs = []
874- for _ in range(3):
875- addrs.append(
876- factory.make_StaticIPAddress(user=self.user))
877- response = self.client.get(reverse('ipaddresses_handler'))
878- self.assertEqual(
879- http.client.OK, response.status_code, response.content)
880- parsed_result = json_load_bytes(response.content)
881- expected = [
882- addr.ip for addr in
883- sorted(addrs, key=lambda addr: getattr(addr, "id"))]
884- observed = [result['ip'] for result in parsed_result]
885- self.assertEqual(expected, observed)
886-
887- def test_POST_release_deallocates_address(self):
888- ipaddress = factory.make_StaticIPAddress(
889- alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
890- response = self.post_release_request(ipaddress.ip)
891- self.assertEqual(
892- http.client.OK, response.status_code, response.content)
893- self.assertIsNone(reload_object(ipaddress))
894-
895- def test_POST_release_deletes_unknown_interface(self):
896- subnet = factory.make_Subnet()
897- unknown_nic = factory.make_Interface(INTERFACE_TYPE.UNKNOWN)
898- ipaddress = unknown_nic.link_subnet(
899- INTERFACE_LINK_TYPE.STATIC, subnet,
900- alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
901-
902- self.post_release_request(ipaddress.ip)
903- self.assertIsNone(reload_object(unknown_nic))
904-
905- def test_POST_release_does_not_delete_interfaces_linked_to_nodes(self):
906- node = factory.make_Node()
907- attached_nic = factory.make_Interface(node=node)
908- subnet = factory.make_Subnet()
909- ipaddress = attached_nic.link_subnet(
910- INTERFACE_LINK_TYPE.STATIC, subnet,
911- alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
912-
913- self.post_release_request(ipaddress.ip)
914- self.assertEqual(attached_nic, reload_object(attached_nic))
915-
916- def test_POST_release_does_not_delete_IP_that_I_dont_own(self):
917- ipaddress = factory.make_StaticIPAddress(user=factory.make_User())
918- response = self.post_release_request(ipaddress.ip)
919- self.assertEqual(
920- http.client.BAD_REQUEST, response.status_code, response.content)
921-
922- def test_POST_release_does_not_delete_other_IPs_I_own(self):
923- ipaddress = factory.make_StaticIPAddress(
924- alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
925- other_address = factory.make_StaticIPAddress(
926- alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)
927- response = self.post_release_request(ipaddress.ip)
928- self.assertEqual(
929- http.client.OK, response.status_code, response.content)
930- self.assertIsNotNone(reload_object(other_address))
931
932=== modified file 'src/maasserver/models/staticipaddress.py'
933--- src/maasserver/models/staticipaddress.py 2016-09-22 02:53:33 +0000
934+++ src/maasserver/models/staticipaddress.py 2016-10-12 17:51:07 +0000
935@@ -558,6 +558,11 @@
936 strtype = type_names.get(self.alloc_type, '%s' % self.alloc_type)
937 return "%s:type=%s" % (self.ip, strtype)
938
939+ @property
940+ def alloc_type_name(self):
941+ """Returns a human-readable representation of the `alloc_type`."""
942+ return IPADDRESS_TYPE_CHOICES_DICT.get(self.alloc_type, "")
943+
944 def get_node(self):
945 """Return the Node of the first Interface connected to this IP
946 address."""
947
948=== modified file 'src/maasserver/models/tests/test_staticipaddress.py'
949--- src/maasserver/models/tests/test_staticipaddress.py 2016-09-22 02:53:33 +0000
950+++ src/maasserver/models/tests/test_staticipaddress.py 2016-10-12 17:51:07 +0000
951@@ -20,6 +20,7 @@
952 INTERFACE_TYPE,
953 IPADDRESS_FAMILY,
954 IPADDRESS_TYPE,
955+ IPADDRESS_TYPE_CHOICES_DICT,
956 IPRANGE_TYPE,
957 )
958 from maasserver.exceptions import (
959@@ -1062,3 +1063,19 @@
960 node_summary = json["node_summary"]
961 self.expectThat(node_summary["system_id"], Equals(node.system_id))
962 self.expectThat(node_summary["node_type"], Equals(node.node_type))
963+
964+
965+class TestAllocTypeName(MAASServerTestCase):
966+
967+ def test__provides_human_readable_values_for_known_types(self):
968+ ip = factory.make_StaticIPAddress()
969+ self.assertThat(
970+ ip.alloc_type_name,
971+ Equals(IPADDRESS_TYPE_CHOICES_DICT[ip.alloc_type]))
972+
973+ def test__returns_empty_string_for_unknown_types(self):
974+ ip = factory.make_StaticIPAddress()
975+ ip.alloc_type = randint(2**16, 2**32)
976+ self.assertThat(
977+ ip.alloc_type_name,
978+ Equals(""))