Merge lp:~blake-rouse/maas/bootresource-simplestreams-endpoint into lp:~maas-committers/maas/trunk
- bootresource-simplestreams-endpoint
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Blake Rouse |
Approved revision: | no longer in the source branch. |
Merged at revision: | 2705 |
Proposed branch: | lp:~blake-rouse/maas/bootresource-simplestreams-endpoint |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
745 lines (+700/-0) 4 files modified
src/maasserver/bootresources.py (+265/-0) src/maasserver/middleware.py (+3/-0) src/maasserver/tests/test_bootresources.py (+421/-0) src/maasserver/urls.py (+11/-0) |
To merge this branch: | bzr merge lp:~blake-rouse/maas/bootresource-simplestreams-endpoint |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gavin Panella (community) | Approve | ||
Review via email: mp+230375@code.launchpad.net |
Commit message
Added simplestreams endpoint on the region controller at path images-stream. Cluster controllers will connect to this endpoint to download the boot resource image data.
Description of the change
Blake Rouse (blake-rouse) wrote : | # |
I will add some comments to the tests, fix the TransactionWrapper, and try to use django url routing.
Thanks for the review.
Gavin Panella (allenap) wrote : | # |
Tip top, thanks for the replies. I'm around tomorrow so ping me whenever you need a follow-up.
Blake Rouse (blake-rouse) wrote : | # |
Having an issue with the TransactionWrapper implementation you suggested. See inline comment.
Gavin Panella (allenap) : | # |
Gavin Panella (allenap) wrote : | # |
Changes look good.
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~blake-rouse/maas/bootresource-simplestreams-endpoint into lp:maas failed. Below is the output from the failed tests.
Ign http://
Ign http://
Get:1 http://
Ign http://
Get:2 http://
Hit http://
Get:3 http://
Hit http://
Get:4 http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Hit http://
Get:5 http://
Get:6 http://
Get:7 http://
Get:8 http://
Ign http://
Ign http://
Get:9 http://
Hit http://
Hit http://
Get:10 http://
Get:11 http://
Get:12 http://
Hit http://
Hit http://
Fetched 1,005 kB in 0s (1,920 kB/s)
Reading package lists...
sudo DEBIAN_
--
Preview Diff
1 | === added file 'src/maasserver/bootresources.py' |
2 | --- src/maasserver/bootresources.py 1970-01-01 00:00:00 +0000 |
3 | +++ src/maasserver/bootresources.py 2014-08-15 01:28:48 +0000 |
4 | @@ -0,0 +1,265 @@ |
5 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
6 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
7 | + |
8 | +"""Boot Resources.""" |
9 | + |
10 | +from __future__ import ( |
11 | + absolute_import, |
12 | + print_function, |
13 | + unicode_literals, |
14 | + ) |
15 | + |
16 | +str = None |
17 | + |
18 | +__metaclass__ = type |
19 | +__all__ = [ |
20 | + "get_simplestream_endpoint", |
21 | + "simplestreams_file_handler", |
22 | + "simplestreams_stream_handler", |
23 | + "SIMPLESTREAMS_URL_REGEXP", |
24 | +] |
25 | + |
26 | +from django.db import ( |
27 | + close_old_connections, |
28 | + transaction, |
29 | + ) |
30 | +from django.http import ( |
31 | + Http404, |
32 | + HttpResponse, |
33 | + StreamingHttpResponse, |
34 | + ) |
35 | +from django.shortcuts import get_object_or_404 |
36 | +from maasserver.enum import BOOT_RESOURCE_TYPE |
37 | +from maasserver.models import ( |
38 | + BootResource, |
39 | + BootResourceFile, |
40 | + BootResourceSet, |
41 | + ) |
42 | +from maasserver.utils import absolute_reverse |
43 | +from simplestreams import util as sutil |
44 | + |
45 | +# Used by maasserver.middleware.AccessMiddleware to allow |
46 | +# anonymous access to the simplestreams endpoint. |
47 | +SIMPLESTREAMS_URL_REGEXP = '^/images-stream/' |
48 | + |
49 | + |
50 | +def get_simplestream_endpoint(): |
51 | + """Returns the simplestreams endpoint for the Region.""" |
52 | + return { |
53 | + 'url': absolute_reverse( |
54 | + 'simplestreams_stream_handler', kwargs={'filename': 'index.json'}), |
55 | + 'selections': [], |
56 | + } |
57 | + |
58 | + |
59 | +class TransactionWrapper: |
60 | + """Wraps `LargeObjectFile` in transaction, so `StreamingHttpResponse` |
61 | + can be used. Once the stream is done, then the transaction is |
62 | + closed. |
63 | + """ |
64 | + |
65 | + def __init__(self, largeobject): |
66 | + self.largeobject = largeobject |
67 | + self._atomic = None |
68 | + self._stream = None |
69 | + |
70 | + def __iter__(self): |
71 | + return self |
72 | + |
73 | + def next(self): |
74 | + if self._atomic is None: |
75 | + self._atomic = transaction.atomic() |
76 | + self._atomic.__enter__() |
77 | + if self._stream is None: |
78 | + self._stream = self.largeobject.open('rb') |
79 | + data = self._stream.read(self.largeobject.block_size) |
80 | + if len(data) == 0: |
81 | + raise StopIteration |
82 | + return data |
83 | + |
84 | + def close(self): |
85 | + if self._stream is not None: |
86 | + self._stream.close() |
87 | + self._stream = None |
88 | + if self._atomic is not None: |
89 | + self._atomic.__exit__() |
90 | + self._atomic = None |
91 | + close_old_connections() |
92 | + |
93 | + |
94 | +class SimpleStreamsHandler: |
95 | + """Simplestreams endpoint, that the clusters talk to. |
96 | + |
97 | + This is not called from piston, as piston uses emitters which |
98 | + breaks the ability to return streaming content. |
99 | + |
100 | + Anyone can access this endpoint. No credentials are required. |
101 | + """ |
102 | + |
103 | + def get_json_response(self, content): |
104 | + """Return `HttpResponse` for JSON content.""" |
105 | + response = HttpResponse(content.encode('utf-8')) |
106 | + response['Content-Type'] = "application/json" |
107 | + return response |
108 | + |
109 | + def get_boot_resource_identifiers(self, resource): |
110 | + """Return tuple (os, arch, subarch, series) for the given resource.""" |
111 | + arch, subarch = resource.split_arch() |
112 | + if resource.rtype == BOOT_RESOURCE_TYPE.UPLOADED: |
113 | + os = 'custom' |
114 | + series = resource.name |
115 | + else: |
116 | + os, series = resource.name.split('/') |
117 | + return (os, arch, subarch, series) |
118 | + |
119 | + def get_product_name(self, resource): |
120 | + """Return product name for the given resource.""" |
121 | + return 'maas:boot:%s:%s:%s:%s' % self.get_boot_resource_identifiers( |
122 | + resource) |
123 | + |
124 | + def gen_complete_boot_resources(self): |
125 | + """Return generator of `BootResource` that contains a complete set.""" |
126 | + resources = BootResource.objects.all() |
127 | + for resource in resources: |
128 | + # Only add resources that have a complete set. |
129 | + if resource.get_latest_complete_set() is None: |
130 | + continue |
131 | + yield resource |
132 | + |
133 | + def gen_products_names(self): |
134 | + """Return generator of avaliable products on the endpoint.""" |
135 | + for resource in self.gen_complete_boot_resources(): |
136 | + yield self.get_product_name(resource) |
137 | + |
138 | + def get_product_index(self): |
139 | + """Returns the streams product index `index.json`.""" |
140 | + products = list(self.gen_products_names()) |
141 | + updated = sutil.timestamp() |
142 | + index = { |
143 | + 'index': { |
144 | + 'maas:v2:download': { |
145 | + 'datatype': "image-downloads", |
146 | + 'path': "streams/v1/maas:v2:download.json", |
147 | + 'updated': updated, |
148 | + 'products': products, |
149 | + 'format': "products:1.0", |
150 | + }, |
151 | + }, |
152 | + 'updated': updated, |
153 | + 'format': "index:1.0" |
154 | + } |
155 | + data = sutil.dump_data(index) + "\n" |
156 | + return self.get_json_response(data) |
157 | + |
158 | + def get_product_item(self, resource, resource_set, rfile): |
159 | + """Returns the item description for the `rfile`.""" |
160 | + os, arch, subarch, series = self.get_boot_resource_identifiers( |
161 | + resource) |
162 | + path = '%s/%s/%s/%s/%s/%s' % ( |
163 | + os, arch, subarch, series, resource_set.version, rfile.filename) |
164 | + item = { |
165 | + 'path': path, |
166 | + 'ftype': rfile.filetype, |
167 | + 'sha256': rfile.largefile.sha256, |
168 | + 'size': rfile.largefile.total_size, |
169 | + } |
170 | + item.update(rfile.extra) |
171 | + return item |
172 | + |
173 | + def get_product_data(self, resource): |
174 | + """Returns the product data for this resource.""" |
175 | + os, arch, subarch, series = self.get_boot_resource_identifiers( |
176 | + resource) |
177 | + versions = {} |
178 | + label = None |
179 | + for resource_set in resource.sets.order_by('id').reverse(): |
180 | + if not resource_set.complete: |
181 | + continue |
182 | + # Set the label to the latest complete set label. In most cases the |
183 | + # label will be the same for all sets. Only time it will differ is |
184 | + # when daily has been enabled for a resource, that was previously |
185 | + # only release. Only the latest version of the resource will be |
186 | + # downloaded. |
187 | + if label is None: |
188 | + label = resource_set.label |
189 | + items = { |
190 | + rfile.filename: self.get_product_item( |
191 | + resource, resource_set, rfile) |
192 | + for rfile in resource_set.files.all() |
193 | + } |
194 | + versions[resource_set.version] = { |
195 | + 'items': items |
196 | + } |
197 | + product = { |
198 | + 'versions': versions, |
199 | + 'subarch': subarch, |
200 | + 'label': label, |
201 | + 'version': series, |
202 | + 'arch': arch, |
203 | + 'release': series, |
204 | + 'krel': series, |
205 | + 'os': os, |
206 | + } |
207 | + product.update(resource.extra) |
208 | + return product |
209 | + |
210 | + def get_product_download(self): |
211 | + """Returns the streams download index `download.json`.""" |
212 | + products = {} |
213 | + for resource in self.gen_complete_boot_resources(): |
214 | + name = self.get_product_name(resource) |
215 | + products[name] = self.get_product_data(resource) |
216 | + updated = sutil.timestamp() |
217 | + index = { |
218 | + 'datatype': "image-downloads", |
219 | + 'updated': updated, |
220 | + 'content_id': "maas:v2:download", |
221 | + 'products': products, |
222 | + 'format': "products:1.0" |
223 | + } |
224 | + data = sutil.dump_data(index) + "\n" |
225 | + return self.get_json_response(data) |
226 | + |
227 | + def streams_handler(self, request, filename): |
228 | + """Handles requests into the "streams/" content.""" |
229 | + if filename == 'index.json': |
230 | + return self.get_product_index() |
231 | + elif filename == 'maas:v2:download.json': |
232 | + return self.get_product_download() |
233 | + raise Http404() |
234 | + |
235 | + def files_handler( |
236 | + self, request, os, arch, subarch, series, version, filename): |
237 | + """Handles requests for getting the boot resource data.""" |
238 | + if os == "custom": |
239 | + name = series |
240 | + else: |
241 | + name = '%s/%s' % (os, series) |
242 | + arch = '%s/%s' % (arch, subarch) |
243 | + resource = get_object_or_404( |
244 | + BootResource, name=name, architecture=arch) |
245 | + try: |
246 | + resource_set = resource.sets.get(version=version) |
247 | + except BootResourceSet.DoesNotExist: |
248 | + raise Http404() |
249 | + try: |
250 | + rfile = resource_set.files.get(filename=filename) |
251 | + except BootResourceFile.DoesNotExist: |
252 | + raise Http404() |
253 | + response = StreamingHttpResponse( |
254 | + TransactionWrapper(rfile.largefile.content), |
255 | + content_type='application/octet-stream') |
256 | + response['Content-Length'] = rfile.largefile.total_size |
257 | + return response |
258 | + |
259 | + |
260 | +def simplestreams_stream_handler(request, filename): |
261 | + handler = SimpleStreamsHandler() |
262 | + return handler.streams_handler(request, filename) |
263 | + |
264 | + |
265 | +def simplestreams_file_handler( |
266 | + request, os, arch, subarch, series, version, filename): |
267 | + handler = SimpleStreamsHandler() |
268 | + return handler.files_handler( |
269 | + request, os, arch, subarch, series, version, filename) |
270 | |
271 | === modified file 'src/maasserver/middleware.py' |
272 | --- src/maasserver/middleware.py 2014-02-11 12:19:25 +0000 |
273 | +++ src/maasserver/middleware.py 2014-08-15 01:28:48 +0000 |
274 | @@ -52,6 +52,7 @@ |
275 | build_request_repr = repr |
276 | from django.utils.http import urlquote_plus |
277 | from maasserver import logger |
278 | +from maasserver.bootresources import SIMPLESTREAMS_URL_REGEXP |
279 | from maasserver.exceptions import ( |
280 | ExternalComponentException, |
281 | MAASAPIException, |
282 | @@ -97,6 +98,8 @@ |
283 | reverse('metadata'), |
284 | # RPC information is for use by clusters; no login. |
285 | reverse('rpc-info'), |
286 | + # Boot resources simple streams endpoint; no login. |
287 | + SIMPLESTREAMS_URL_REGEXP, |
288 | # API calls are protected by piston. |
289 | settings.API_URL_REGEXP, |
290 | ] |
291 | |
292 | === added file 'src/maasserver/tests/test_bootresources.py' |
293 | --- src/maasserver/tests/test_bootresources.py 1970-01-01 00:00:00 +0000 |
294 | +++ src/maasserver/tests/test_bootresources.py 2014-08-15 01:28:48 +0000 |
295 | @@ -0,0 +1,421 @@ |
296 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
297 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
298 | + |
299 | +"""Test maasserver.bootresources.""" |
300 | + |
301 | +from __future__ import ( |
302 | + absolute_import, |
303 | + print_function, |
304 | + unicode_literals, |
305 | + ) |
306 | + |
307 | +str = None |
308 | + |
309 | +__metaclass__ = type |
310 | +__all__ = [] |
311 | + |
312 | +import httplib |
313 | +import json |
314 | +from random import randint |
315 | + |
316 | +from django.core.urlresolvers import reverse |
317 | +from django.db import transaction |
318 | +from django.http import StreamingHttpResponse |
319 | +from django.test.client import Client |
320 | +from maasserver.bootresources import get_simplestream_endpoint |
321 | +from maasserver.enum import ( |
322 | + BOOT_RESOURCE_FILE_TYPE, |
323 | + BOOT_RESOURCE_TYPE, |
324 | + ) |
325 | +from maasserver.testing.factory import factory |
326 | +from maasserver.testing.testcase import MAASServerTestCase |
327 | +from maasserver.utils import absolute_reverse |
328 | +from maastesting.testcase import MAASTestCase |
329 | +from testtools.matchers import ContainsAll |
330 | + |
331 | + |
332 | +class TestHelpers(MAASServerTestCase): |
333 | + """Tests for `maasserver.bootresources` helpers.""" |
334 | + |
335 | + def test_get_simplestreams_endpoint(self): |
336 | + endpoint = get_simplestream_endpoint() |
337 | + self.assertEqual( |
338 | + absolute_reverse( |
339 | + 'simplestreams_stream_handler', |
340 | + kwargs={'filename': 'index.json'}), |
341 | + endpoint['url']) |
342 | + self.assertEqual([], endpoint['selections']) |
343 | + |
344 | + |
345 | +class TestSimpleStreamsHandler(MAASServerTestCase): |
346 | + """Tests for `maasserver.bootresources.SimpleStreamsHandler`.""" |
347 | + |
348 | + def reverse_stream_handler(self, filename): |
349 | + return reverse( |
350 | + 'simplestreams_stream_handler', kwargs={'filename': filename}) |
351 | + |
352 | + def reverse_file_handler( |
353 | + self, os, arch, subarch, series, version, filename): |
354 | + return reverse( |
355 | + 'simplestreams_file_handler', kwargs={ |
356 | + 'os': os, |
357 | + 'arch': arch, |
358 | + 'subarch': subarch, |
359 | + 'series': series, |
360 | + 'version': version, |
361 | + 'filename': filename, |
362 | + }) |
363 | + |
364 | + def get_stream_client(self, filename): |
365 | + return self.client.get(self.reverse_stream_handler(filename)) |
366 | + |
367 | + def get_file_client(self, os, arch, subarch, series, version, filename): |
368 | + return self.client.get( |
369 | + self.reverse_file_handler( |
370 | + os, arch, subarch, series, version, filename)) |
371 | + |
372 | + def get_product_name_for_resource(self, resource): |
373 | + arch, subarch = resource.architecture.split('/') |
374 | + if resource.rtype == BOOT_RESOURCE_TYPE.UPLOADED: |
375 | + os = 'custom' |
376 | + series = resource.name |
377 | + else: |
378 | + os, series = resource.name.split('/') |
379 | + return 'maas:boot:%s:%s:%s:%s' % (os, arch, subarch, series) |
380 | + |
381 | + def make_usable_product_boot_resource(self): |
382 | + resource = factory.make_usable_boot_resource() |
383 | + return self.get_product_name_for_resource(resource), resource |
384 | + |
385 | + def test_streams_other_than_allowed_returns_404(self): |
386 | + allowed_paths = [ |
387 | + 'index.json', |
388 | + 'maas:v2:download.json', |
389 | + ] |
390 | + invalid_paths = [ |
391 | + '%s.json' % factory.make_name('path') |
392 | + for _ in range(3) |
393 | + ] |
394 | + for path in allowed_paths: |
395 | + response = self.get_stream_client(path) |
396 | + self.assertEqual(httplib.OK, response.status_code) |
397 | + for path in invalid_paths: |
398 | + response = self.get_stream_client(path) |
399 | + self.assertEqual(httplib.NOT_FOUND, response.status_code) |
400 | + |
401 | + def test_streams_product_index_contains_keys(self): |
402 | + response = self.get_stream_client('index.json') |
403 | + output = json.loads(response.content) |
404 | + self.assertThat(output, ContainsAll(['index', 'updated', 'format'])) |
405 | + |
406 | + def test_streams_product_index_format_is_index_1(self): |
407 | + response = self.get_stream_client('index.json') |
408 | + output = json.loads(response.content) |
409 | + self.assertEqual('index:1.0', output['format']) |
410 | + |
411 | + def test_streams_product_index_index_has_maas_v2_download(self): |
412 | + response = self.get_stream_client('index.json') |
413 | + output = json.loads(response.content) |
414 | + self.assertThat(output['index'], ContainsAll(['maas:v2:download'])) |
415 | + |
416 | + def test_streams_product_index_maas_v2_download_contains_keys(self): |
417 | + response = self.get_stream_client('index.json') |
418 | + output = json.loads(response.content) |
419 | + self.assertThat( |
420 | + output['index']['maas:v2:download'], |
421 | + ContainsAll([ |
422 | + 'datatype', 'path', 'updated', 'products', 'format'])) |
423 | + |
424 | + def test_streams_product_index_maas_v2_download_has_valid_values(self): |
425 | + response = self.get_stream_client('index.json') |
426 | + output = json.loads(response.content) |
427 | + self.assertEqual( |
428 | + 'image-downloads', |
429 | + output['index']['maas:v2:download']['datatype']) |
430 | + self.assertEqual( |
431 | + 'streams/v1/maas:v2:download.json', |
432 | + output['index']['maas:v2:download']['path']) |
433 | + self.assertEqual( |
434 | + 'products:1.0', |
435 | + output['index']['maas:v2:download']['format']) |
436 | + |
437 | + def test_streams_product_index_empty_products(self): |
438 | + response = self.get_stream_client('index.json') |
439 | + output = json.loads(response.content) |
440 | + self.assertEqual( |
441 | + [], |
442 | + output['index']['maas:v2:download']['products']) |
443 | + |
444 | + def test_streams_product_index_empty_with_incomplete_resource(self): |
445 | + resource = factory.make_boot_resource() |
446 | + factory.make_boot_resource_set(resource) |
447 | + response = self.get_stream_client('index.json') |
448 | + output = json.loads(response.content) |
449 | + self.assertEqual( |
450 | + [], |
451 | + output['index']['maas:v2:download']['products']) |
452 | + |
453 | + def test_streams_product_index_with_resources(self): |
454 | + products = [] |
455 | + for _ in range(3): |
456 | + product, _ = self.make_usable_product_boot_resource() |
457 | + products.append(product) |
458 | + response = self.get_stream_client('index.json') |
459 | + output = json.loads(response.content) |
460 | + # Product listing should be the same as all of the completed |
461 | + # boot resources in the database. |
462 | + self.assertItemsEqual( |
463 | + products, |
464 | + output['index']['maas:v2:download']['products']) |
465 | + |
466 | + def test_streams_product_download_contains_keys(self): |
467 | + response = self.get_stream_client('maas:v2:download.json') |
468 | + output = json.loads(response.content) |
469 | + self.assertThat(output, ContainsAll([ |
470 | + 'datatype', 'updated', 'content_id', 'products', 'format'])) |
471 | + |
472 | + def test_streams_product_download_has_valid_values(self): |
473 | + response = self.get_stream_client('maas:v2:download.json') |
474 | + output = json.loads(response.content) |
475 | + self.assertEqual('image-downloads', output['datatype']) |
476 | + self.assertEqual('maas:v2:download', output['content_id']) |
477 | + self.assertEqual('products:1.0', output['format']) |
478 | + |
479 | + def test_streams_product_download_empty_products(self): |
480 | + response = self.get_stream_client('maas:v2:download.json') |
481 | + output = json.loads(response.content) |
482 | + self.assertEqual( |
483 | + {}, |
484 | + output['products']) |
485 | + |
486 | + def test_streams_product_download_empty_with_incomplete_resource(self): |
487 | + resource = factory.make_boot_resource() |
488 | + factory.make_boot_resource_set(resource) |
489 | + response = self.get_stream_client('maas:v2:download.json') |
490 | + output = json.loads(response.content) |
491 | + self.assertEqual( |
492 | + {}, |
493 | + output['products']) |
494 | + |
495 | + def test_streams_product_download_has_valid_product_keys(self): |
496 | + products = [] |
497 | + for _ in range(3): |
498 | + product, _ = self.make_usable_product_boot_resource() |
499 | + products.append(product) |
500 | + response = self.get_stream_client('maas:v2:download.json') |
501 | + output = json.loads(response.content) |
502 | + # Product listing should be the same as all of the completed |
503 | + # boot resources in the database. |
504 | + self.assertThat( |
505 | + output['products'], |
506 | + ContainsAll(products)) |
507 | + |
508 | + def test_streams_product_download_product_contains_keys(self): |
509 | + product, _ = self.make_usable_product_boot_resource() |
510 | + response = self.get_stream_client('maas:v2:download.json') |
511 | + output = json.loads(response.content) |
512 | + self.assertThat( |
513 | + output['products'][product], |
514 | + ContainsAll([ |
515 | + 'versions', 'subarch', 'label', 'version', |
516 | + 'arch', 'release', 'krel', 'os'])) |
517 | + |
518 | + def test_streams_product_download_product_has_valid_values(self): |
519 | + product, resource = self.make_usable_product_boot_resource() |
520 | + _, _, os, arch, subarch, series = product.split(':') |
521 | + label = resource.get_latest_complete_set().label |
522 | + response = self.get_stream_client('maas:v2:download.json') |
523 | + output = json.loads(response.content) |
524 | + output_product = output['products'][product] |
525 | + self.assertEqual(subarch, output_product['subarch']) |
526 | + self.assertEqual(label, output_product['label']) |
527 | + self.assertEqual(series, output_product['version']) |
528 | + self.assertEqual(arch, output_product['arch']) |
529 | + self.assertEqual(series, output_product['release']) |
530 | + self.assertEqual(series, output_product['krel']) |
531 | + self.assertEqual(os, output_product['os']) |
532 | + for key, value in resource.extra.items(): |
533 | + self.assertEqual(value, output_product[key]) |
534 | + |
535 | + def test_streams_product_download_product_uses_latest_complete_label(self): |
536 | + product, resource = self.make_usable_product_boot_resource() |
537 | + # Incomplete resource_set |
538 | + factory.make_boot_resource_set(resource) |
539 | + newest_set = factory.make_boot_resource_set(resource) |
540 | + factory.make_boot_resource_file_with_content(newest_set) |
541 | + response = self.get_stream_client('maas:v2:download.json') |
542 | + output = json.loads(response.content) |
543 | + output_product = output['products'][product] |
544 | + self.assertEqual(newest_set.label, output_product['label']) |
545 | + |
546 | + def test_streams_product_download_product_contains_multiple_versions(self): |
547 | + resource = factory.make_boot_resource() |
548 | + resource_sets = [ |
549 | + factory.make_boot_resource_set(resource) |
550 | + for _ in range(3) |
551 | + ] |
552 | + versions = [] |
553 | + for resource_set in resource_sets: |
554 | + factory.make_boot_resource_file_with_content(resource_set) |
555 | + versions.append(resource_set.version) |
556 | + product = self.get_product_name_for_resource(resource) |
557 | + response = self.get_stream_client('maas:v2:download.json') |
558 | + output = json.loads(response.content) |
559 | + self.assertThat( |
560 | + output['products'][product]['versions'], |
561 | + ContainsAll(versions)) |
562 | + |
563 | + def test_streams_product_download_product_version_contains_items(self): |
564 | + product, resource = self.make_usable_product_boot_resource() |
565 | + resource_set = resource.get_latest_complete_set() |
566 | + items = [ |
567 | + rfile.filename |
568 | + for rfile in resource_set.files.all() |
569 | + ] |
570 | + response = self.get_stream_client('maas:v2:download.json') |
571 | + output = json.loads(response.content) |
572 | + version = output['products'][product]['versions'][resource_set.version] |
573 | + self.assertThat( |
574 | + version['items'], |
575 | + ContainsAll(items)) |
576 | + |
577 | + def test_streams_product_download_product_item_contains_keys(self): |
578 | + product, resource = self.make_usable_product_boot_resource() |
579 | + resource_set = resource.get_latest_complete_set() |
580 | + resource_file = resource_set.files.order_by('?')[0] |
581 | + response = self.get_stream_client('maas:v2:download.json') |
582 | + output = json.loads(response.content) |
583 | + version = output['products'][product]['versions'][resource_set.version] |
584 | + self.assertThat( |
585 | + version['items'][resource_file.filename], |
586 | + ContainsAll(['path', 'ftype', 'sha256', 'size'])) |
587 | + |
588 | + def test_streams_product_download_product_item_has_valid_values(self): |
589 | + product, resource = self.make_usable_product_boot_resource() |
590 | + _, _, os, arch, subarch, series = product.split(':') |
591 | + resource_set = resource.get_latest_complete_set() |
592 | + resource_file = resource_set.files.order_by('?')[0] |
593 | + path = '%s/%s/%s/%s/%s/%s' % ( |
594 | + os, arch, subarch, series, resource_set.version, |
595 | + resource_file.filename) |
596 | + response = self.get_stream_client('maas:v2:download.json') |
597 | + output = json.loads(response.content) |
598 | + version = output['products'][product]['versions'][resource_set.version] |
599 | + item = version['items'][resource_file.filename] |
600 | + self.assertEqual(path, item['path']) |
601 | + self.assertEqual(resource_file.filetype, item['ftype']) |
602 | + self.assertEqual(resource_file.largefile.sha256, item['sha256']) |
603 | + self.assertEqual(resource_file.largefile.total_size, item['size']) |
604 | + for key, value in resource_file.extra.items(): |
605 | + self.assertEqual(value, item[key]) |
606 | + |
607 | + def test_download_invalid_boot_resource_returns_404(self): |
608 | + os = factory.make_name('os') |
609 | + series = factory.make_name('series') |
610 | + arch = factory.make_name('arch') |
611 | + subarch = factory.make_name('subarch') |
612 | + version = factory.make_name('version') |
613 | + filename = factory.make_name('filename') |
614 | + response = self.get_file_client( |
615 | + os, arch, subarch, series, version, filename) |
616 | + self.assertEqual(httplib.NOT_FOUND, response.status_code) |
617 | + |
618 | + def test_download_invalid_version_returns_404(self): |
619 | + product, resource = self.make_usable_product_boot_resource() |
620 | + _, _, os, arch, subarch, series = product.split(':') |
621 | + version = factory.make_name('version') |
622 | + filename = factory.make_name('filename') |
623 | + response = self.get_file_client( |
624 | + os, arch, subarch, series, version, filename) |
625 | + self.assertEqual(httplib.NOT_FOUND, response.status_code) |
626 | + |
627 | + def test_download_invalid_filename_returns_404(self): |
628 | + product, resource = self.make_usable_product_boot_resource() |
629 | + _, _, os, arch, subarch, series = product.split(':') |
630 | + resource_set = resource.get_latest_complete_set() |
631 | + version = resource_set.version |
632 | + filename = factory.make_name('filename') |
633 | + response = self.get_file_client( |
634 | + os, arch, subarch, series, version, filename) |
635 | + self.assertEqual(httplib.NOT_FOUND, response.status_code) |
636 | + |
637 | + def test_download_valid_path_returns_200(self): |
638 | + product, resource = self.make_usable_product_boot_resource() |
639 | + _, _, os, arch, subarch, series = product.split(':') |
640 | + resource_set = resource.get_latest_complete_set() |
641 | + version = resource_set.version |
642 | + resource_file = resource_set.files.order_by('?')[0] |
643 | + filename = resource_file.filename |
644 | + response = self.get_file_client( |
645 | + os, arch, subarch, series, version, filename) |
646 | + self.assertEqual(httplib.OK, response.status_code) |
647 | + |
648 | + def test_download_returns_streaming_response(self): |
649 | + product, resource = self.make_usable_product_boot_resource() |
650 | + _, _, os, arch, subarch, series = product.split(':') |
651 | + resource_set = resource.get_latest_complete_set() |
652 | + version = resource_set.version |
653 | + resource_file = resource_set.files.order_by('?')[0] |
654 | + filename = resource_file.filename |
655 | + with resource_file.largefile.content.open('rb') as stream: |
656 | + content = stream.read() |
657 | + response = self.get_file_client( |
658 | + os, arch, subarch, series, version, filename) |
659 | + self.assertIsInstance(response, StreamingHttpResponse) |
660 | + self.assertEqual(content, b''.join(response.streaming_content)) |
661 | + |
662 | + |
663 | +class TestTransactionWrapper(MAASTestCase): |
664 | + """Tests the use of StreamingHttpResponse(TransactionWrapper(stream)). |
665 | + |
666 | + We do not run this inside of `MAASServerTestCase` as that wraps a |
667 | + transaction around each test. This removes that behavior so we can |
668 | + test that the transaction is remaining open for all of the content. |
669 | + """ |
670 | + |
671 | + def test_download(self): |
672 | + # Do the setup inside of a transaction, as we are running in a test |
673 | + # that doesn't enable transactions per test. |
674 | + with transaction.atomic(): |
675 | + os = factory.make_name('os') |
676 | + series = factory.make_name('series') |
677 | + arch = factory.make_name('arch') |
678 | + subarch = factory.make_name('subarch') |
679 | + name = '%s/%s' % (os, series) |
680 | + architecture = '%s/%s' % (arch, subarch) |
681 | + version = factory.make_name('version') |
682 | + filetype = factory.pick_enum(BOOT_RESOURCE_FILE_TYPE) |
683 | + # We set the filename to the same value as filetype, as in most |
684 | + # cases this will always be true. The simplestreams content from |
685 | + # maas.ubuntu.com, is formatted this way. |
686 | + filename = filetype |
687 | + size = randint(1024, 2048) |
688 | + content = factory.make_string(size=size) |
689 | + resource = factory.make_boot_resource( |
690 | + rtype=BOOT_RESOURCE_TYPE.SYNCED, name=name, |
691 | + architecture=architecture) |
692 | + resource_set = factory.make_boot_resource_set( |
693 | + resource, version=version) |
694 | + largefile = factory.make_large_file(content=content, size=size) |
695 | + factory.make_boot_resource_file( |
696 | + resource_set, largefile, filename=filename, filetype=filetype) |
697 | + |
698 | + # Outside of the transaction, we run the actual test. The client will |
699 | + # run inside of its own transaction, but once the streaming response |
700 | + # is returned that transaction will be closed. |
701 | + client = Client() |
702 | + response = client.get( |
703 | + reverse( |
704 | + 'simplestreams_file_handler', kwargs={ |
705 | + 'os': os, |
706 | + 'arch': arch, |
707 | + 'subarch': subarch, |
708 | + 'series': series, |
709 | + 'version': version, |
710 | + 'filename': filename, |
711 | + })) |
712 | + |
713 | + # If TransactionWrapper does not work, then a ProgramError will be |
714 | + # thrown. If it works then content will match. |
715 | + self.assertEqual(content, b''.join(response.streaming_content)) |
716 | + self.assertTrue(largefile.content.closed) |
717 | |
718 | === modified file 'src/maasserver/urls.py' |
719 | --- src/maasserver/urls.py 2014-08-13 21:49:35 +0000 |
720 | +++ src/maasserver/urls.py 2014-08-15 01:28:48 +0000 |
721 | @@ -21,6 +21,10 @@ |
722 | url, |
723 | ) |
724 | from django.contrib.auth.decorators import user_passes_test |
725 | +from maasserver.bootresources import ( |
726 | + simplestreams_file_handler, |
727 | + simplestreams_stream_handler, |
728 | + ) |
729 | from maasserver.enum import NODEGROUP_STATUS |
730 | from maasserver.models import Node |
731 | from maasserver.views import TextTemplateView |
732 | @@ -104,6 +108,13 @@ |
733 | 'maasserver.views', |
734 | url(r'^accounts/login/$', login, name='login'), |
735 | url( |
736 | + r'^images-stream/streams/v1/(?P<filename>.*)$', |
737 | + simplestreams_stream_handler, name='simplestreams_stream_handler'), |
738 | + url( |
739 | + r'^images-stream/(?P<os>.*)/(?P<arch>.*)/(?P<subarch>.*)/' |
740 | + '(?P<series>.*)/(?P<version>.*)/(?P<filename>.*)$', |
741 | + simplestreams_file_handler, name='simplestreams_file_handler'), |
742 | + url( |
743 | r'^robots\.txt$', TextTemplateView.as_view( |
744 | template_name='maasserver/robots.txt'), |
745 | name='robots'), |
Looks good... but I don't understand a lot of it.
The branch is a bit too big. I don't mean just line count, I mean that there's a lot going on. Things like TransactionWrapper and its tests could probably have been landed separately, and SimpleStreamsHa ndler could have been landed/reviewed in stages.
Pretty much all tests could do with some comments to explain what's going on. Remember that I'm you in about a month, when you've lost all the context you've currently got in-brain.
Basically it _looks_ fine, but add some comments, please :) Also, I have a suggestion for a simpler TransactionWrapper.