Merge lp:~julian-edwards/maas/api-reserve-user-ip 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: 2495
Proposed branch: lp:~julian-edwards/maas/api-reserve-user-ip
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 318 lines (+256/-2)
4 files modified
src/maasserver/api.py (+76/-0)
src/maasserver/testing/factory.py (+2/-2)
src/maasserver/tests/test_api_ipaddresses.py (+174/-0)
src/maasserver/urls_api.py (+4/-0)
To merge this branch: bzr merge lp:~julian-edwards/maas/api-reserve-user-ip
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+225113@code.launchpad.net

Commit message

Add API to enable the reservation of USER_RESERVED static IP addresses.

Description of the change

I decided to make this a collection on a top-level API endpoint, so that in the future we can add more features to enable people to query more information about in-use static IPs. We also probably need an admin-only API function to get a view on all the IPs in use in the system (and web UI) but that can wait until later.

So now you can do:

POST /ipaddresses?op=reserve&net=<foonet/bits>
POST /ipaddresses?op=release&ip=<fooip>
GET /ipaddresses (to show all your own addresses in use)

To post a comment you must log in.
Revision history for this message
Raphaël Badin (rvb) wrote :

Looks good.

An alternate design would have been to stick the new endpoint on the nodegroupinterface but I guess adding a new top level endpoint (/ipaddresses/) is a bit simpler.

As I said on IRC, I'd advice refactoring the reserve() API call to use a form. A form is just the natural abstraction when doing any kind of validation that is longer than two lines. It has several advantages:
- the code will be easier to test (the form it only doing the validation so testing the different error cases will be much simpler)
- the tests which only exercise the form will be much faster (because they don't have to deal with the HTTP layer)
- the form can be reused in the UI

Also, I've got a couple of minor remarks, see the inline comments.

review: Approve
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Thanks for the review!

On Tuesday 01 Jul 2014 11:58:12 you wrote:
> Review: Approve
>
> Looks good.
>
> An alternate design would have been to stick the new endpoint on the
> nodegroupinterface but I guess adding a new top level endpoint
> (/ipaddresses/) is a bit simpler.

I explicitly didn't want to do that for several reasons:
 1. addressing the NGI is problematic
 2. it would obviate the desired mechanism of selecting a network - the CIDR
 3. it is not future proof for querying other types of addresses (as I said in
the cover note!)

>
> As I said on IRC, I'd advice refactoring the reserve() API call to use a
> form. A form is just the natural abstraction when doing any kind of
> validation that is longer than two lines. It has several advantages: - the
> code will be easier to test (the form it only doing the validation so
> testing the different error cases will be much simpler) - the tests which
> only exercise the form will be much faster (because they don't have to deal
> with the HTTP layer) - the form can be reused in the UI

I must admit I thought I wouldn't need a form when I started this, and then
after it was done I had some regrets, and also figured you might say that :)

> You'll get a KeyError if it's not there; that's not what I call proper
> testing :).

I think it's fine, the test will stop with an error and it'll be obvious why.

> > + del returned_address['created']
> > + expected = dict(
>
> You could use testtools.matchers.MatchesStructure here.

Ah yes I had forgotten about that, thanks for the reminder!

J

Revision history for this message
Raphaël Badin (rvb) wrote :

On 07/01/2014 02:15 PM, Julian Edwards wrote:
> Thanks for the review!
>
> On Tuesday 01 Jul 2014 11:58:12 you wrote:
>> Review: Approve
>>
>> Looks good.
>>
>> An alternate design would have been to stick the new endpoint on the
>> nodegroupinterface but I guess adding a new top level endpoint
>> (/ipaddresses/) is a bit simpler.
>
> I explicitly didn't want to do that for several reasons:
> 1. addressing the NGI is problematic
> 2. it would obviate the desired mechanism of selecting a network - the CIDR
> 3. it is not future proof for querying other types of addresses (as I said in
> the cover note!)

Yeah, I agree with your conclusion here.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On 01/07/14 21:58, Raphaël Badin wrote:
> As I said on IRC, I'd advice refactoring the reserve() API call to use a form. A form is just the natural abstraction when doing any kind of validation that is longer than two lines. It has several advantages:

I'd like to land this now and refactor later because I need some advice
for setting up this form (it's not a Django thing I am all that familiar
with). Also I'd like to get doing some QA on this and start a release
ready for next week!

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

The attempt to merge lp:~julian-edwards/maas/api-reserve-user-ip 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
Hit http://security.ubuntu.com trusty-security Release
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
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
Get:2 http://nova.clouds.archive.ubuntu.com trusty-updates Release [58.5 kB]
Hit http://security.ubuntu.com trusty-security/main Sources
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
Ign http://security.ubuntu.com trusty-security/main Translation-en_US
Ign http://security.ubuntu.com trusty-security/universe Translation-en_US
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [78.4 kB]
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [55.6 kB]
Get:5 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [210 kB]
Get:6 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [148 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en_US
Fetched 552 kB in 0s (1,421 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...

Revision history for this message
Julian Edwards (julian-edwards) wrote :

It looks like getRandomNetwork() has a bug. I can't disable this test as that factory method is used everywhere. Retrying, and filing a bug.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2014-06-27 02:26:51 +0000
3+++ src/maasserver/api.py 2014-07-01 06:06:42 +0000
4@@ -225,6 +225,7 @@
5 NodeGroupInterface,
6 SSHKey,
7 SSLKey,
8+ StaticIPAddress,
9 Tag,
10 Zone,
11 )
12@@ -3658,3 +3659,78 @@
13 @classmethod
14 def resource_uri(cls, *args, **kwargs):
15 return ('networks_handler', [])
16+
17+
18+class IPAddressesHandler(OperationsHandler):
19+ """Manage IP addresses allocated by MAAS."""
20+ api_doc_section_name = "IP Addresses"
21+
22+ model = StaticIPAddress
23+ fields = ('alloc_type', 'created', 'ip')
24+ create = update = delete = None
25+
26+ @classmethod
27+ def resource_uri(cls, *args, **kwargs):
28+ return ('ipaddresses_handler', [])
29+
30+ def claim_ip(self, user, interface):
31+ """Attempt to get a USER_RESERVED StaticIPAddress for `user` on
32+ `interface`.
33+
34+ :raises StaticIPAddressExhaustion: If no IPs available.
35+ """
36+ return StaticIPAddress.objects.allocate_new(
37+ range_low=interface.static_ip_range_low,
38+ range_high=interface.static_ip_range_high,
39+ alloc_type=IPADDRESS_TYPE.USER_RESERVED)
40+
41+ @operation(idempotent=False)
42+ def reserve(self, request):
43+ """Reserve an IP address for use outside of MAAS.
44+
45+ Returns an IP adddress for which MAAS will not allow any of its
46+ known devices and Nodes to use; it is free for use by the requesting
47+ user until released by the user.
48+
49+ :param network: CIDR representation of the network on which the IP
50+ reservation is required. e.g. 10.1.2.0/24
51+ :type network: unicode
52+ """
53+ network = get_mandatory_param(request.POST, "network")
54+ # Validate the passed network.
55+ try:
56+ valid_network = netaddr.IPNetwork(network)
57+ except netaddr.core.AddrFormatError:
58+ raise MAASAPIBadRequest("Invalid network parameter %s" % network)
59+
60+ # Match the network to a nodegroupinterface.
61+ interfaces = (
62+ NodeGroupInterface.objects.filter(
63+ nodegroup__status=NODEGROUP_STATUS.ACCEPTED)
64+ .exclude(static_ip_range_low__isnull=True)
65+ .exclude(static_ip_range_high__isnull=True)
66+ )
67+ for interface in interfaces:
68+ if valid_network == interface.network:
69+ # Winner winner chicken dinner.
70+ return self.claim_ip(request.user, interface)
71+ raise MAASAPIBadRequest("No network found matching %s" % network)
72+
73+ @operation(idempotent=False)
74+ def release(self, request):
75+ """Release an IP address that was previously reserved by the user.
76+
77+ :param ip: The IP address to release.
78+ :type ip: unicode
79+ """
80+ ip = get_mandatory_param(request.POST, "ip")
81+ staticaddress = get_object_or_404(
82+ StaticIPAddress, ip=ip, user=request.user)
83+ staticaddress.deallocate()
84+
85+ def read(self, request):
86+ """List IPAddresses.
87+
88+ Get a listing of all IPAddresses allocated to the requesting user.
89+ """
90+ return StaticIPAddress.objects.filter(user=request.user).order_by('id')
91
92=== modified file 'src/maasserver/testing/factory.py'
93--- src/maasserver/testing/factory.py 2014-06-27 08:31:11 +0000
94+++ src/maasserver/testing/factory.py 2014-07-01 06:06:42 +0000
95@@ -436,7 +436,7 @@
96 return node
97
98 def make_staticipaddress(self, ip=None, alloc_type=IPADDRESS_TYPE.AUTO,
99- mac=None):
100+ mac=None, user=None):
101 """Create and return a StaticIPAddress model object.
102
103 If a non-None `mac` is passed, connect this IP address to the
104@@ -444,7 +444,7 @@
105 """
106 if ip is None:
107 ip = self.getRandomIPAddress()
108- ipaddress = StaticIPAddress(ip=ip, alloc_type=alloc_type)
109+ ipaddress = StaticIPAddress(ip=ip, alloc_type=alloc_type, user=user)
110 ipaddress.save()
111 if mac is not None:
112 MACStaticIPAddressLink(
113
114=== added file 'src/maasserver/tests/test_api_ipaddresses.py'
115--- src/maasserver/tests/test_api_ipaddresses.py 1970-01-01 00:00:00 +0000
116+++ src/maasserver/tests/test_api_ipaddresses.py 2014-07-01 06:06:42 +0000
117@@ -0,0 +1,174 @@
118+# Copyright 2014 Canonical Ltd. This software is licensed under the
119+# GNU Affero General Public License version 3 (see the file LICENSE).
120+
121+"""Tests for IP addresses API."""
122+
123+from __future__ import (
124+ absolute_import,
125+ print_function,
126+ unicode_literals,
127+ )
128+
129+str = None
130+
131+__metaclass__ = type
132+__all__ = []
133+
134+import httplib
135+import json
136+
137+from django.core.urlresolvers import reverse
138+from maasserver.enum import (
139+ IPADDRESS_TYPE,
140+ NODEGROUP_STATUS,
141+ )
142+from maasserver.models import StaticIPAddress
143+from maasserver.testing import reload_object
144+from maasserver.testing.api import APITestCase
145+from maasserver.testing.factory import factory
146+
147+
148+class TestNetworksAPI(APITestCase):
149+
150+ def make_interface(self, status=NODEGROUP_STATUS.ACCEPTED, **kwargs):
151+ ng = factory.make_node_group(status=status, **kwargs)
152+ return ng.get_managed_interfaces()[0]
153+
154+ def post_reservation_request(self, net):
155+ params = {
156+ 'op': 'reserve',
157+ 'network': unicode(net),
158+ }
159+ return self.client.post(reverse('ipaddresses_handler'), params)
160+
161+ def post_release_request(self, ip):
162+ params = {
163+ 'op': 'release',
164+ 'ip': ip,
165+ }
166+ return self.client.post(reverse('ipaddresses_handler'), params)
167+
168+ def assertNoMatchingNetworkError(self, response, net):
169+ self.assertEqual(
170+ httplib.BAD_REQUEST, response.status_code, response.content)
171+ self.assertEqual(
172+ "No network found matching %s" % unicode(net),
173+ response.content)
174+
175+ def test_handler_path(self):
176+ self.assertEqual(
177+ '/api/1.0/ipaddresses/', reverse('ipaddresses_handler'))
178+
179+ def test_POST_reserve_creates_ipaddress(self):
180+ interface = self.make_interface()
181+ net = interface.network
182+ response = self.post_reservation_request(net)
183+ self.assertEqual(httplib.OK, response.status_code)
184+ returned_address = json.loads(response.content)
185+ [staticipaddress] = StaticIPAddress.objects.all()
186+ # We don't need to test the value of the 'created' datetime
187+ # field. By removing it, we also test for its presence.
188+ del returned_address['created']
189+ expected = dict(
190+ alloc_type=staticipaddress.alloc_type,
191+ ip=staticipaddress.ip,
192+ resource_uri=reverse('ipaddresses_handler'),
193+ )
194+ self.assertEqual(expected, returned_address)
195+ self.assertEqual(
196+ IPADDRESS_TYPE.USER_RESERVED, staticipaddress.alloc_type)
197+
198+ def test_POST_reserve_errors_for_no_matching_interface(self):
199+ interface = self.make_interface()
200+ net = factory.getRandomNetwork(but_not=[interface.network])
201+ response = self.post_reservation_request(net)
202+ self.assertNoMatchingNetworkError(response, net)
203+
204+ def test_POST_reserve_errors_for_interface_with_no_IP_range(self):
205+ interface = self.make_interface()
206+ net = interface.network
207+ interface.static_ip_range_low = None
208+ interface.static_ip_range_high = None
209+ interface.save()
210+ response = self.post_reservation_request(net)
211+ self.assertNoMatchingNetworkError(response, net)
212+
213+ def test_POST_reserve_errors_for_invalid_network(self):
214+ net = factory.getRandomString()
215+ response = self.post_reservation_request(net)
216+ self.assertEqual(
217+ httplib.BAD_REQUEST, response.status_code, response.content)
218+ self.assertEqual(
219+ "Invalid network parameter %s" % net,
220+ response.content)
221+
222+ def test_GET_returns_ipaddresses(self):
223+ original_ipaddress = factory.make_staticipaddress(
224+ user=self.logged_in_user)
225+ response = self.client.get(reverse('ipaddresses_handler'))
226+ self.assertEqual(httplib.OK, response.status_code, response.content)
227+
228+ parsed_result = json.loads(response.content)
229+ self.assertEqual(1, len(parsed_result), response.content)
230+ [returned_address] = parsed_result
231+ fields = {'alloc_type', 'ip'}
232+ self.assertEqual(
233+ fields.union({'resource_uri', 'created'}),
234+ set(returned_address.keys()))
235+ expected_values = {
236+ field: getattr(original_ipaddress, field)
237+ for field in fields
238+ if field not in ('resource_uri', 'created')
239+ }
240+ # We don't need to test the value of the 'created' datetime
241+ # field.
242+ del returned_address['created']
243+ expected_values['resource_uri'] = reverse('ipaddresses_handler')
244+ self.assertEqual(expected_values, returned_address)
245+
246+ def test_GET_returns_empty_if_no_ipaddresses(self):
247+ response = self.client.get(reverse('ipaddresses_handler'))
248+ self.assertEqual(httplib.OK, response.status_code, response.content)
249+ self.assertEqual([], json.loads(response.content))
250+
251+ def test_GET_only_returns_request_users_addresses(self):
252+ ipaddress = factory.make_staticipaddress(user=self.logged_in_user)
253+ factory.make_staticipaddress(user=factory.make_user())
254+ response = self.client.get(reverse('ipaddresses_handler'))
255+ self.assertEqual(httplib.OK, response.status_code, response.content)
256+ parsed_result = json.loads(response.content)
257+ [returned_address] = parsed_result
258+ self.assertEqual(ipaddress.ip, returned_address['ip'])
259+
260+ def test_GET_sorts_by_id(self):
261+ addrs = []
262+ for _ in range(3):
263+ addrs.append(
264+ factory.make_staticipaddress(user=self.logged_in_user))
265+ response = self.client.get(reverse('ipaddresses_handler'))
266+ self.assertEqual(httplib.OK, response.status_code, response.content)
267+ parsed_result = json.loads(response.content)
268+ expected = [
269+ addr.ip for addr in
270+ sorted(addrs, key=lambda addr: getattr(addr, "id"))]
271+ observed = [result['ip'] for result in parsed_result]
272+ self.assertEqual(expected, observed)
273+
274+ def test_POST_release_deallocates_address(self):
275+ ipaddress = factory.make_staticipaddress(user=self.logged_in_user)
276+ response = self.post_release_request(ipaddress.ip)
277+ self.assertEqual(httplib.OK, response.status_code, response.content)
278+ self.assertIsNone(reload_object(ipaddress))
279+
280+ def test_POST_release_does_not_delete_IP_that_I_dont_own(self):
281+ ipaddress = factory.make_staticipaddress(user=factory.make_user())
282+ response = self.post_release_request(ipaddress.ip)
283+ self.assertEqual(
284+ httplib.NOT_FOUND, response.status_code, response.content)
285+
286+ def test_POST_release_does_not_delete_other_IPs_I_own(self):
287+ ipaddress = factory.make_staticipaddress(user=self.logged_in_user)
288+ other_address = factory.make_staticipaddress(user=self.logged_in_user)
289+ response = self.post_release_request(ipaddress.ip)
290+ self.assertEqual(httplib.OK, response.status_code, response.content)
291+ self.assertIsNotNone(reload_object(other_address))
292
293=== modified file 'src/maasserver/urls_api.py'
294--- src/maasserver/urls_api.py 2014-06-09 19:58:06 +0000
295+++ src/maasserver/urls_api.py 2014-07-01 06:06:42 +0000
296@@ -33,6 +33,7 @@
297 describe,
298 FileHandler,
299 FilesHandler,
300+ IPAddressesHandler,
301 MaasHandler,
302 NetworkHandler,
303 NetworksHandler,
304@@ -67,6 +68,8 @@
305 account_handler = RestrictedResource(AccountHandler, authentication=api_auth)
306 files_handler = RestrictedResource(FilesHandler, authentication=api_auth)
307 file_handler = RestrictedResource(FileHandler, authentication=api_auth)
308+ipaddresses_handler = RestrictedResource(
309+ IPAddressesHandler, authentication=api_auth)
310 network_handler = RestrictedResource(NetworkHandler, authentication=api_auth)
311 networks_handler = RestrictedResource(NetworksHandler, authentication=api_auth)
312 node_handler = RestrictedResource(NodeHandler, authentication=api_auth)
313@@ -180,6 +183,7 @@
314 url(r'^users/(?P<username>[^/]+)/$', user_handler, name='user_handler'),
315 url(r'^zones/(?P<name>[^/]+)/$', zone_handler, name='zone_handler'),
316 url(r'^zones/$', zones_handler, name='zones_handler'),
317+ url(r'^ipaddresses/$', ipaddresses_handler, name='ipaddresses_handler'),
318 )
319
320