Merge lp:~blake-rouse/maas/bootresource-simplestreams-endpoint into lp:~maas-committers/maas/trunk

Proposed by Blake Rouse
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
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.

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

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 SimpleStreamsHandler 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.

review: Needs Information
Revision history for this message
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.

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

Tip top, thanks for the replies. I'm around tomorrow so ping me whenever you need a follow-up.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Having an issue with the TransactionWrapper implementation you suggested. See inline comment.

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

Changes look good.

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

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://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Get:2 http://security.ubuntu.com trusty-security Release [59.7 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [59.7 kB]
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
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:5 http://security.ubuntu.com trusty-security/main Sources [39.6 kB]
Get:6 http://security.ubuntu.com trusty-security/universe Sources [11.3 kB]
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [129 kB]
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [46.2 kB]
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:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [108 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [74.9 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [293 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [181 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
Fetched 1,005 kB in 0s (1,920 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 pep8 postgresql pyflakes 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-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mimeparse python-mock python-netaddr python-netifaces python-nose python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi p...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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'),