Merge lp:~blake-rouse/maas/multipart-image-upload into lp:~maas-committers/maas/trunk
- multipart-image-upload
- Merge into trunk
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 | ||||||||
Related bugs: |
|
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.
Description of the change
MAAS Lander (maas-lander) wrote : | # |
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://
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://
Hit http://
Hit http://
Get:9 http://
Get:10 http://
Get:11 http://
Get:12 http://
Hit http://
Hit http://
Ign http://
Ign http://
Fetched 1,049 kB in 0s (1,809 kB/s)
Reading package lists...
sudo DEBIAN_
--
Preview Diff
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 |
Looks good, comments inline but no blockers.