Merge lp:~julian-edwards/maas/api-reserve-user-ip into lp:~maas-committers/maas/trunk
- api-reserve-user-ip
- Merge into trunk
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 |
Related bugs: |
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?
POST /ipaddresses?
GET /ipaddresses (to show all your own addresses in use)
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_
> > + expected = dict(
>
> You could use testtools.
Ah yes I had forgotten about that, thanks for the reminder!
J
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.
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!
MAAS Lander (maas-lander) wrote : | # |
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://
Hit http://
Hit http://
Ign http://
Ign http://
Hit http://
Get:1 http://
Hit http://
Get:2 http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Ign http://
Ign http://
Hit http://
Hit http://
Ign http://
Ign http://
Get:3 http://
Get:4 http://
Get:5 http://
Get:6 http://
Hit http://
Hit http://
Ign http://
Ign http://
Fetched 552 kB in 0s (1,421 kB/s)
Reading package lists...
sudo DEBIAN_
--
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
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 |
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.