Merge lp:~blake-rouse/maas/multipart-image-upload 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: 2887
Proposed branch: lp:~blake-rouse/maas/multipart-image-upload
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 642 lines (+422/-24)
7 files modified
src/maasserver/api/boot_resources.py (+105/-16)
src/maasserver/api/tests/test_boot_resources.py (+218/-3)
src/maasserver/exceptions.py (+4/-0)
src/maasserver/forms.py (+55/-5)
src/maasserver/models/largefile.py (+16/-0)
src/maasserver/models/tests/test_largefile.py (+17/-0)
src/maasserver/urls_api.py (+7/-0)
To merge this branch: bzr merge lp:~blake-rouse/maas/multipart-image-upload
Reviewer Review Type Date Requested Status
Jason Hobbs (community) Approve
Review via email: mp+233122@code.launchpad.net

Commit message

Added the ability to create a boot resource without any content, this requires passing the size and sha256 for the file. New upload api added giving the ability to upload the content of the boot resource file in sections.

To post a comment you must log in.
Revision history for this message
Jason Hobbs (jason-hobbs) wrote :

Looks good, comments inline but no blockers.

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

The attempt to merge lp:~blake-rouse/maas/multipart-image-upload into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty 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 [42.3 kB]
Get:6 http://security.ubuntu.com trusty-security/universe Sources [10.8 kB]
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [135 kB]
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [47.0 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [114 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [78.7 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [308 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [192 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/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 1,049 kB in 0s (1,809 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind 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 pytho...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/boot_resources.py'
2--- src/maasserver/api/boot_resources.py 2014-08-31 02:47:48 +0000
3+++ src/maasserver/api/boot_resources.py 2014-09-03 13:27:07 +0000
4@@ -15,9 +15,11 @@
5 __all__ = [
6 'BootResourceHandler',
7 'BootResourcesHandler',
8+ 'BootResourceFileUploadHandler',
9 ]
10
11 import httplib
12+import os
13
14 from django.core.exceptions import ValidationError
15 from django.core.files.uploadedfile import SimpleUploadedFile
16@@ -29,16 +31,23 @@
17 operation,
18 OperationsHandler,
19 )
20-from maasserver.api.utils import get_mandatory_param
21+from maasserver.api.utils import get_optional_param
22 from maasserver.bootresources import import_resources
23 from maasserver.enum import (
24 BOOT_RESOURCE_TYPE,
25 BOOT_RESOURCE_TYPE_CHOICES_DICT,
26 )
27-from maasserver.exceptions import MAASAPIBadRequest
28-from maasserver.forms import BootResourceForm
29+from maasserver.exceptions import (
30+ MAASAPIBadRequest,
31+ MAASAPIForbidden,
32+ )
33+from maasserver.forms import (
34+ BootResourceForm,
35+ BootResourceNoContentForm,
36+ )
37 from maasserver.models import (
38 BootResource,
39+ BootResourceFile,
40 NodeGroup,
41 )
42 from piston.emitters import JSONEmitter
43@@ -53,10 +62,21 @@
44 }
45
46
47+# XXX blake_r 2014-09-22 bug=1361370: We currently allow both generated and
48+# uploaded resource to be uploaded. This is until the MAAS can generate its
49+# own images.
50+ALLOW_UPLOAD_RTYPES = [
51+ BOOT_RESOURCE_TYPE.GENERATED,
52+ BOOT_RESOURCE_TYPE.UPLOADED,
53+ ]
54+
55+
56 def get_content_parameter(request):
57 """Get the "content" parameter from a POST or PUT."""
58- content_file = get_mandatory_param(request.FILES, 'content')
59- return content_file.read()
60+ content = get_optional_param(request.FILES, 'content', None)
61+ if content is None:
62+ return None
63+ return content.read()
64
65
66 def boot_resource_file_to_dict(rfile):
67@@ -70,6 +90,12 @@
68 }
69 if not dict_representation['complete']:
70 dict_representation['progress'] = rfile.largefile.progress
71+ resource = rfile.resource_set.resource
72+ if resource.rtype in ALLOW_UPLOAD_RTYPES:
73+ dict_representation['upload_uri'] = reverse(
74+ 'boot_resource_file_upload_handler',
75+ args=[resource.id, rfile.id])
76+
77 return dict_representation
78
79
80@@ -166,18 +192,26 @@
81 data = request.data
82 if data is None:
83 data = {}
84- content = SimpleUploadedFile(
85- content=get_content_parameter(request),
86- name='file', content_type='application/octet-stream')
87 if 'filetype' not in data:
88 data['filetype'] = 'tgz'
89- form = BootResourceForm(data=data, files={'content': content})
90+ file_content = get_content_parameter(request)
91+ if file_content is not None:
92+ content = SimpleUploadedFile(
93+ content=file_content, name='file',
94+ content_type='application/octet-stream')
95+ form = BootResourceForm(data=data, files={
96+ 'content': content,
97+ })
98+ else:
99+ form = BootResourceNoContentForm(data=data)
100 if not form.is_valid():
101 raise ValidationError(form.errors)
102 resource = form.save()
103
104- # Boot resource is now available. Have the clusters sync boot images.
105- NodeGroup.objects.import_boot_images_on_accepted_clusters()
106+ # If an upload contained the full file, then we can have the clusters
107+ # sync a new resource.
108+ if file_content is not None:
109+ NodeGroup.objects.import_boot_images_on_accepted_clusters()
110
111 stream = json_object(
112 boot_resource_to_dict(resource, with_sets=True), request)
113@@ -200,17 +234,14 @@
114
115
116 class BootResourceHandler(OperationsHandler):
117- """Manage a boot resource.
118-
119- This functionality is only available to administrators.
120- """
121+ """Manage a boot resource."""
122 api_doc_section_name = "Boot resource"
123 model = BootResource
124
125 create = update = None
126
127 def read(self, request, id):
128- """Read a boot source."""
129+ """Read a boot resource."""
130 resource = get_object_or_404(BootResource, id=id)
131 stream = json_object(
132 boot_resource_to_dict(resource, with_sets=True), request)
133@@ -233,3 +264,61 @@
134 else:
135 id = resource.id
136 return ('boot_resource_handler', (id, ))
137+
138+
139+class BootResourceFileUploadHandler(OperationsHandler):
140+ """Upload a boot resource file."""
141+ api_doc_section_name = "Boot resource file upload"
142+ model = BootResource
143+
144+ read = create = delete = None
145+
146+ @admin_method
147+ def update(self, request, id, file_id):
148+ """Upload piece of boot resource file."""
149+ resource = get_object_or_404(BootResource, id=id)
150+ rfile = get_object_or_404(BootResourceFile, id=file_id)
151+ size = request.META.get('CONTENT_LENGTH', 0)
152+ data = request.body
153+ if size == 0:
154+ raise MAASAPIBadRequest("Missing data.")
155+ if size != len(data):
156+ raise MAASAPIBadRequest(
157+ "Content-Length doesn't equal size of recieved data.")
158+ if resource.rtype not in ALLOW_UPLOAD_RTYPES:
159+ raise MAASAPIForbidden(
160+ "Cannot upload to a resource of type: %s. " % resource.rtype)
161+ if rfile.largefile.complete:
162+ raise MAASAPIBadRequest(
163+ "Cannot upload to a complete file.")
164+
165+ with rfile.largefile.content.open('wb') as stream:
166+ stream.seek(0, os.SEEK_END)
167+
168+ # Check that the uploading data will not make the file larger
169+ # than expected.
170+ current_size = stream.tell()
171+ if current_size + size > rfile.largefile.total_size:
172+ raise MAASAPIBadRequest(
173+ "Too much data recieved.")
174+
175+ stream.write(data)
176+
177+ if rfile.largefile.complete:
178+ if not rfile.largefile.valid:
179+ raise MAASAPIBadRequest(
180+ "Saved content does not match given SHA256 value.")
181+ NodeGroup.objects.import_boot_images_on_accepted_clusters()
182+ return rc.ALL_OK
183+
184+ @classmethod
185+ def resource_uri(cls, resource=None, rfile=None):
186+ if resource is None:
187+ id = 'id'
188+ else:
189+ id = resource.id
190+ if rfile is None:
191+ file_id = 'id'
192+ else:
193+ file_id = rfile.id
194+ return ('boot_resource_file_upload_handler', (id, file_id))
195
196=== modified file 'src/maasserver/api/tests/test_boot_resources.py'
197--- src/maasserver/api/tests/test_boot_resources.py 2014-08-31 02:47:48 +0000
198+++ src/maasserver/api/tests/test_boot_resources.py 2014-09-03 13:27:07 +0000
199@@ -30,7 +30,11 @@
200 BOOT_RESOURCE_TYPE,
201 BOOT_RESOURCE_TYPE_CHOICES_DICT,
202 )
203-from maasserver.models import BootResource
204+from maasserver.fields import LargeObjectFile
205+from maasserver.models import (
206+ BootResource,
207+ LargeFile,
208+ )
209 from maasserver.testing.api import APITestCase
210 from maasserver.testing.architecture import make_usable_architecture
211 from maasserver.testing.factory import factory
212@@ -56,7 +60,8 @@
213 total_size = random.randint(1024, 2048)
214 content = factory.make_string(size)
215 largefile = factory.make_large_file(content=content, size=total_size)
216- resource = factory.make_boot_resource()
217+ resource = factory.make_boot_resource(
218+ rtype=BOOT_RESOURCE_TYPE.UPLOADED)
219 resource_set = factory.make_boot_resource_set(resource)
220 rfile = factory.make_boot_resource_file(resource_set, largefile)
221 dict_representation = boot_resource_file_to_dict(rfile)
222@@ -67,6 +72,11 @@
223 self.assertEqual(False, dict_representation['complete'])
224 self.assertEqual(
225 rfile.largefile.progress, dict_representation['progress'])
226+ self.assertEqual(
227+ reverse(
228+ 'boot_resource_file_upload_handler',
229+ args=[resource.id, rfile.id]),
230+ dict_representation['upload_uri'])
231
232 def test_boot_resource_set_to_dict(self):
233 resource = factory.make_boot_resource()
234@@ -111,7 +121,7 @@
235 dict_representation['sets'][resource_set.version])
236
237
238-class TestBootSourcesAPI(APITestCase):
239+class TestBootResourcesAPI(APITestCase):
240 """Test the the boot resource API."""
241
242 def test_handler_path(self):
243@@ -254,6 +264,73 @@
244 rfile = resource_set.files.first()
245 self.assertEqual(BOOT_RESOURCE_FILE_TYPE.ROOT_TGZ, rfile.filetype)
246
247+ def test_POST_creates_boot_resource_with_already_existing_largefile(self):
248+ self.become_admin()
249+
250+ largefile = factory.make_large_file()
251+ name = factory.make_name('name')
252+ architecture = make_usable_architecture(self)
253+ params = {
254+ 'name': name,
255+ 'architecture': architecture,
256+ 'sha256': largefile.sha256,
257+ 'size': largefile.total_size,
258+ }
259+ response = self.client.post(
260+ reverse('boot_resources_handler'), params)
261+ self.assertEqual(httplib.CREATED, response.status_code)
262+ parsed_result = json.loads(response.content)
263+
264+ resource = BootResource.objects.get(id=parsed_result['id'])
265+ resource_set = resource.sets.first()
266+ rfile = resource_set.files.first()
267+ self.assertEqual(largefile, rfile.largefile)
268+
269+ def test_POST_creates_boot_resource_with_empty_largefile(self):
270+ self.become_admin()
271+
272+ # Create a largefile to get a random sha256 and size. We delete it
273+ # immediately so the new resource does not pick it up.
274+ largefile = factory.make_large_file()
275+ largefile.delete()
276+
277+ name = factory.make_name('name')
278+ architecture = make_usable_architecture(self)
279+ params = {
280+ 'name': name,
281+ 'architecture': architecture,
282+ 'sha256': largefile.sha256,
283+ 'size': largefile.total_size,
284+ }
285+ response = self.client.post(
286+ reverse('boot_resources_handler'), params)
287+ self.assertEqual(httplib.CREATED, response.status_code)
288+ parsed_result = json.loads(response.content)
289+
290+ resource = BootResource.objects.get(id=parsed_result['id'])
291+ resource_set = resource.sets.first()
292+ rfile = resource_set.files.first()
293+ self.assertEqual(
294+ (largefile.sha256, largefile.total_size, False),
295+ (rfile.largefile.sha256, rfile.largefile.total_size,
296+ rfile.largefile.complete))
297+
298+ def test_POST_validates_size_matches_total_size_for_largefile(self):
299+ self.become_admin()
300+
301+ largefile = factory.make_large_file()
302+ name = factory.make_name('name')
303+ architecture = make_usable_architecture(self)
304+ params = {
305+ 'name': name,
306+ 'architecture': architecture,
307+ 'sha256': largefile.sha256,
308+ 'size': largefile.total_size + 1,
309+ }
310+ response = self.client.post(
311+ reverse('boot_resources_handler'), params)
312+ self.assertEqual(httplib.BAD_REQUEST, response.status_code)
313+
314 def test_POST_returns_full_definition_of_boot_resource(self):
315 self.become_admin()
316
317@@ -342,3 +419,141 @@
318 resource = factory.make_boot_resource()
319 response = self.client.delete(get_boot_resource_uri(resource))
320 self.assertEqual(httplib.FORBIDDEN, response.status_code)
321+
322+
323+class TestBootResourceFileUploadAPI(APITestCase):
324+
325+ def get_boot_resource_file_upload_uri(self, rfile):
326+ """Return a boot resource file's URI on the API."""
327+ return reverse(
328+ 'boot_resource_file_upload_handler',
329+ args=[rfile.resource_set.resource.id, rfile.id])
330+
331+ def make_empty_resource_file(self, rtype=None, content=None):
332+ # Create a largefile to use the generated content,
333+ # sha256, and total_size.
334+ if content is None:
335+ content = factory.make_bytes(1024)
336+ total_size = len(content)
337+ largefile = factory.make_large_file(content=content, size=total_size)
338+ sha256 = largefile.sha256
339+ with largefile.content.open('rb') as stream:
340+ content = stream.read()
341+ largefile.delete()
342+
343+ # Empty largefile
344+ largeobject = LargeObjectFile()
345+ largeobject.open().close()
346+ largefile = LargeFile.objects.create(
347+ sha256=sha256, total_size=total_size, content=largeobject)
348+
349+ if rtype is None:
350+ rtype = BOOT_RESOURCE_TYPE.UPLOADED
351+ resource = factory.make_boot_resource(rtype=rtype)
352+ resource_set = factory.make_boot_resource_set(resource)
353+ rfile = factory.make_boot_resource_file(resource_set, largefile)
354+ return rfile, content
355+
356+ def read_content(self, rfile):
357+ """Return the content saved in resource file."""
358+ with rfile.largefile.content.open('rb') as stream:
359+ return stream.read()
360+
361+ def test_handler_path(self):
362+ self.assertEqual(
363+ '/api/1.0/boot-resources/3/upload/5/',
364+ reverse('boot_resource_file_upload_handler', args=['3', '5']))
365+
366+ def test_PUT_resource_file_writes_content(self):
367+ self.become_admin()
368+ rfile, content = self.make_empty_resource_file()
369+ response = self.client.put(
370+ self.get_boot_resource_file_upload_uri(rfile), data=content)
371+ self.assertEqual(httplib.OK, response.status_code, response.content)
372+ self.assertEqual(content, self.read_content(rfile))
373+
374+ def test_PUT_requires_admin(self):
375+ rfile, content = self.make_empty_resource_file()
376+ response = self.client.put(
377+ self.get_boot_resource_file_upload_uri(rfile), data=content)
378+ self.assertEqual(
379+ httplib.FORBIDDEN, response.status_code, response.content)
380+
381+ def test_PUT_returns_bad_request_when_no_content(self):
382+ self.become_admin()
383+ rfile, _ = self.make_empty_resource_file()
384+ response = self.client.put(
385+ self.get_boot_resource_file_upload_uri(rfile))
386+ self.assertEqual(
387+ httplib.BAD_REQUEST, response.status_code, response.content)
388+
389+ def test_PUT_returns_forbidden_when_resource_is_synced(self):
390+ self.become_admin()
391+ rfile, content = self.make_empty_resource_file(
392+ BOOT_RESOURCE_TYPE.SYNCED)
393+ response = self.client.put(
394+ self.get_boot_resource_file_upload_uri(rfile), data=content)
395+ self.assertEqual(
396+ httplib.FORBIDDEN, response.status_code, response.content)
397+
398+ def test_PUT_returns_bad_request_when_resource_file_is_complete(self):
399+ self.become_admin()
400+ rfile, content = self.make_empty_resource_file()
401+ with rfile.largefile.content.open('wb') as stream:
402+ stream.write(content)
403+
404+ response = self.client.put(
405+ self.get_boot_resource_file_upload_uri(rfile), data=content)
406+ self.assertEqual(
407+ httplib.BAD_REQUEST, response.status_code, response.content)
408+
409+ def test_PUT_returns_bad_request_when_content_is_too_large(self):
410+ self.become_admin()
411+ rfile, content = self.make_empty_resource_file()
412+ content = factory.make_bytes(len(content) + 1)
413+ response = self.client.put(
414+ self.get_boot_resource_file_upload_uri(rfile), data=content)
415+ self.assertEqual(
416+ httplib.BAD_REQUEST, response.status_code, response.content)
417+
418+ def test_PUT_returns_bad_request_when_content_doesnt_match_sha256(self):
419+ self.become_admin()
420+ rfile, content = self.make_empty_resource_file()
421+ content = factory.make_bytes(size=len(content))
422+ response = self.client.put(
423+ self.get_boot_resource_file_upload_uri(rfile), data=content)
424+ self.assertEqual(
425+ httplib.BAD_REQUEST, response.status_code, response.content)
426+
427+ def test_PUT_on_complete_calls_clusters_to_import_boot_images(self):
428+ self.become_admin()
429+ nodegroup = MagicMock()
430+ self.patch(boot_resources, 'NodeGroup', nodegroup)
431+
432+ rfile, content = self.make_empty_resource_file()
433+ response = self.client.put(
434+ self.get_boot_resource_file_upload_uri(rfile), data=content)
435+ self.assertEqual(
436+ httplib.OK, response.status_code, response.content)
437+ self.assertThat(
438+ nodegroup.objects.import_boot_images_on_accepted_clusters,
439+ MockCalledOnceWith())
440+
441+ def test_PUT_with_multiple_requests_and_large_content(self):
442+ self.become_admin()
443+
444+ # Get large amount of data to test with
445+ content = factory.make_bytes(1 << 24) # 16MB
446+ rfile, _ = self.make_empty_resource_file(content=content)
447+ split_content = [
448+ content[i:i + (1 << 22)]
449+ for i in range(0, len(content), 1 << 22) # Loop a total of 4 times
450+ ]
451+
452+ for send_content in split_content:
453+ response = self.client.put(
454+ self.get_boot_resource_file_upload_uri(rfile),
455+ data=send_content)
456+ self.assertEqual(
457+ httplib.OK, response.status_code, response.content)
458+ self.assertEqual(content, self.read_content(rfile))
459
460=== modified file 'src/maasserver/exceptions.py'
461--- src/maasserver/exceptions.py 2014-08-27 09:48:49 +0000
462+++ src/maasserver/exceptions.py 2014-09-03 13:27:07 +0000
463@@ -77,6 +77,10 @@
464 api_error = httplib.NOT_FOUND
465
466
467+class MAASAPIForbidden(MAASAPIException):
468+ api_error = httplib.FORBIDDEN
469+
470+
471 class Unauthorized(MAASAPIException):
472 """HTTP error 401: Unauthorized. Login required."""
473 api_error = httplib.UNAUTHORIZED
474
475=== modified file 'src/maasserver/forms.py'
476--- src/maasserver/forms.py 2014-09-03 09:07:15 +0000
477+++ src/maasserver/forms.py 2014-09-03 13:27:07 +0000
478@@ -88,6 +88,7 @@
479 NodeActionError,
480 )
481 from maasserver.fields import (
482+ LargeObjectFile,
483 MACAddressFormField,
484 NodeGroupFormField,
485 )
486@@ -2236,10 +2237,11 @@
487 elif value == 'ddtgz':
488 return BOOT_RESOURCE_FILE_TYPE.ROOT_DD
489
490- def create_resource_file(self, resource_set, filetype, content):
491+ def create_resource_file(self, resource_set, data):
492 """Creates a new `BootResourceFile` on the given resource set."""
493- filetype = self.get_resource_filetype(filetype)
494- largefile = LargeFile.objects.get_or_create_file_from_content(content)
495+ filetype = self.get_resource_filetype(data['filetype'])
496+ largefile = LargeFile.objects.get_or_create_file_from_content(
497+ data['content'])
498 return BootResourceFile.objects.create(
499 resource_set=resource_set, largefile=largefile,
500 filename=filetype, filetype=filetype)
501@@ -2277,6 +2279,54 @@
502 resource.save()
503 resource_set = self.create_resource_set(resource, label)
504 self.create_resource_file(
505- resource_set, self.cleaned_data['filetype'],
506- self.cleaned_data['content'])
507+ resource_set, self.cleaned_data)
508 return resource
509+
510+
511+class BootResourceNoContentForm(BootResourceForm):
512+ """Form for uploading boot resources with no content."""
513+
514+ class Meta:
515+ model = BootResource
516+ fields = (
517+ 'name',
518+ 'title',
519+ 'architecture',
520+ 'filetype',
521+ 'sha256',
522+ 'size',
523+ )
524+
525+ sha256 = forms.CharField(
526+ label="SHA256", max_length=64, min_length=64, required=True)
527+
528+ size = forms.IntegerField(
529+ label="Size", required=True)
530+
531+ def __init__(self, *args, **kwargs):
532+ super(BootResourceNoContentForm, self).__init__(*args, **kwargs)
533+ # Remove content field, as this form does not use it
534+ del self.fields['content']
535+
536+ def create_resource_file(self, resource_set, data):
537+ """Creates a new `BootResourceFile` on the given resource set."""
538+ filetype = self.get_resource_filetype(data['filetype'])
539+ sha256 = data['sha256']
540+ total_size = data['size']
541+ largefile = LargeFile.objects.get_file(sha256)
542+ if largefile is not None:
543+ if total_size != largefile.total_size:
544+ raise ValidationError(
545+ "File already exists with sha256 that is of "
546+ "different size.")
547+ else:
548+ # Create an empty large object. It must be opened and closed
549+ # for the object to be created in the database.
550+ largeobject = LargeObjectFile()
551+ largeobject.open().close()
552+ largefile = LargeFile.objects.create(
553+ sha256=sha256, total_size=total_size,
554+ content=largeobject)
555+ return BootResourceFile.objects.create(
556+ resource_set=resource_set, largefile=largefile,
557+ filename=filetype, filetype=filetype)
558
559=== modified file 'src/maasserver/models/largefile.py'
560--- src/maasserver/models/largefile.py 2014-08-21 18:44:53 +0000
561+++ src/maasserver/models/largefile.py 2014-09-03 13:27:07 +0000
562@@ -126,6 +126,22 @@
563 """`content` has been completely saved."""
564 return (self.total_size == self.size)
565
566+ @property
567+ def valid(self):
568+ """All content has been written and stored SHA256 value is the same
569+ as the calculated SHA256 value stored in the database.
570+
571+ Note: Depending on the size of the file, this can take some time.
572+ """
573+ if not self.complete:
574+ return False
575+ sha256 = hashlib.sha256()
576+ with self.content.open('rb') as stream:
577+ for data in stream:
578+ sha256.update(data)
579+ hexdigest = sha256.hexdigest()
580+ return hexdigest == self.sha256
581+
582 def delete(self, *args, **kwargs):
583 """Delete this object.
584
585
586=== modified file 'src/maasserver/models/tests/test_largefile.py'
587--- src/maasserver/models/tests/test_largefile.py 2014-08-21 18:44:53 +0000
588+++ src/maasserver/models/tests/test_largefile.py 2014-09-03 13:27:07 +0000
589@@ -100,6 +100,23 @@
590 largefile = factory.make_large_file()
591 self.assertTrue(largefile.complete)
592
593+ def test_valid_returns_False_when_complete_is_False(self):
594+ size = randint(512, 1024)
595+ total_size = randint(1025, 2048)
596+ content = factory.make_string(size=size)
597+ largefile = factory.make_large_file(content, size=total_size)
598+ self.assertFalse(largefile.valid)
599+
600+ def test_valid_returns_False_when_content_doesnt_have_equal_sha256(self):
601+ largefile = factory.make_large_file()
602+ with largefile.content.open('wb') as stream:
603+ stream.write(factory.make_string(size=largefile.total_size))
604+ self.assertFalse(largefile.valid)
605+
606+ def test_valid_returns_True_when_content_has_equal_sha256(self):
607+ largefile = factory.make_large_file()
608+ self.assertTrue(largefile.valid)
609+
610 def test_delete_calls_unlink_on_content(self):
611 largefile = factory.make_large_file()
612 content = largefile.content
613
614=== modified file 'src/maasserver/urls_api.py'
615--- src/maasserver/urls_api.py 2014-08-28 16:08:16 +0000
616+++ src/maasserver/urls_api.py 2014-09-03 13:27:07 +0000
617@@ -22,6 +22,7 @@
618 from maasserver.api.auth import api_auth
619 from maasserver.api.boot_images import BootImagesHandler
620 from maasserver.api.boot_resources import (
621+ BootResourceFileUploadHandler,
622 BootResourceHandler,
623 BootResourcesHandler,
624 )
625@@ -107,6 +108,8 @@
626 account_handler = RestrictedResource(AccountHandler, authentication=api_auth)
627 boot_resource_handler = RestrictedResource(
628 BootResourceHandler, authentication=api_auth)
629+boot_resource_file_upload_handler = RestrictedResource(
630+ BootResourceFileUploadHandler, authentication=api_auth)
631 boot_resources_handler = RestrictedResource(
632 BootResourcesHandler, authentication=api_auth)
633 files_handler = RestrictedResource(FilesHandler, authentication=api_auth)
634@@ -244,6 +247,10 @@
635 url(
636 r'^boot-resources/(?P<id>[^/]+)/$',
637 boot_resource_handler, name='boot_resource_handler'),
638+ url(
639+ r'^boot-resources/(?P<id>[^/]+)/upload/(?P<file_id>[^/]+)/$',
640+ boot_resource_file_upload_handler,
641+ name='boot_resource_file_upload_handler'),
642 )
643
644