Merge lp:~blake-rouse/maas/maascli-multipart-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: 2971
Proposed branch: lp:~blake-rouse/maas/maascli-multipart-upload
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 676 lines (+498/-9)
11 files modified
src/maascli/actions/boot_resources_create.py (+186/-0)
src/maascli/actions/tests/test_boot_resources_create.py (+207/-0)
src/maascli/api.py (+22/-1)
src/maascli/tests/test_api.py (+29/-0)
src/maascli/utils.py (+15/-0)
src/maasserver/api/boot_resources.py (+3/-1)
src/maasserver/api/boot_source_selections.py (+2/-2)
src/maasserver/api/boot_sources.py (+2/-2)
src/maasserver/api/doc.py (+5/-3)
src/maasserver/api/tests/test_doc.py (+12/-0)
src/maasserver/models/bootresourcefile.py (+15/-0)
To merge this branch: bzr merge lp:~blake-rouse/maas/maascli-multipart-upload
Reviewer Review Type Date Requested Status
Jason Hobbs (community) Approve
Review via email: mp+234142@code.launchpad.net

Commit message

Modified the maascli to allow for custom Action classes based on the handler and action. Added the BootResourceCreateAction, that performs the upload of the content parameter using multiple part uploading, that the MAAS API now supports.

Also contains a drive by fix for LargeFile not being deleted correctly when the reference object is the last.

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

I have comments and questions inline but I don't think any of them are blockers.

review: Approve
Revision history for this message
Blake Rouse (blake-rouse) :
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (18.8 KiB)

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

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 InRelease
Get:2 http://security.ubuntu.com trusty-security Release [59.7 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
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]
Get:5 http://security.ubuntu.com trusty-security/main Sources [43.8 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [10.8 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [139 kB]
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:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [47.2 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/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
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [118 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [83.7 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [316 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [201 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,080 kB in 0s (1,770 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 pyt...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'src/maascli/actions'
2=== added file 'src/maascli/actions/__init__.py'
3=== added file 'src/maascli/actions/boot_resources_create.py'
4--- src/maascli/actions/boot_resources_create.py 1970-01-01 00:00:00 +0000
5+++ src/maascli/actions/boot_resources_create.py 2014-09-12 03:32:27 +0000
6@@ -0,0 +1,186 @@
7+# Copyright 2014 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+"""MAAS Boot Resources Action."""
11+
12+from __future__ import (
13+ absolute_import,
14+ print_function,
15+ unicode_literals,
16+ )
17+
18+str = None
19+
20+__metaclass__ = type
21+__all__ = [
22+ 'action_class'
23+ ]
24+
25+from cStringIO import StringIO
26+import hashlib
27+import json
28+from urlparse import urljoin
29+
30+from apiclient.multipart import (
31+ build_multipart_message,
32+ encode_multipart_message,
33+ )
34+from maascli.api import (
35+ Action,
36+ http_request,
37+ )
38+from maascli.command import CommandError
39+
40+# Send 4MB of data per request.
41+CHUNK_SIZE = 1 << 22
42+
43+
44+class BootResourcesCreateAction(Action):
45+ """Provides custom logic to the boot-resources create action.
46+
47+ Command: maas username boot-resources create
48+
49+ The create command has the ability to upload the content in pieces, using
50+ the upload_uri that is returned in the response. This class provides the
51+ logic to upload over that API.
52+ """
53+
54+ def __call__(self, options):
55+ # TODO: this is el-cheapo URI Template
56+ # <http://tools.ietf.org/html/rfc6570> support; use uritemplate-py
57+ # <https://github.com/uri-templates/uritemplate-py> here?
58+ uri = self.uri.format(**vars(options))
59+ content = self.initial_request(uri, options)
60+
61+ # Get the created resource file for the boot resource
62+ rfile = self.get_resource_file(content)
63+ if rfile is None:
64+ print("Failed to identify created resource.")
65+ raise CommandError(2)
66+ if rfile['complete']:
67+ # File already existed in the database, so no
68+ # reason to upload it.
69+ return
70+
71+ # Upload content
72+ data = dict(options.data)
73+ upload_uri = urljoin(uri, rfile['upload_uri'])
74+ self.upload_content(
75+ upload_uri, data['content'], insecure=options.insecure)
76+
77+ def initial_request(self, uri, options):
78+ """Performs the initial POST request, to create the boot resource."""
79+ # Bundle things up ready to throw over the wire.
80+ body, headers = self.prepare_initial_payload(options.data)
81+
82+ # Headers are returned as a list, but they must be a dict for
83+ # the signing machinery.
84+ headers = dict(headers)
85+
86+ # Sign request if credentials have been provided.
87+ if self.credentials is not None:
88+ self.sign(uri, headers, self.credentials)
89+
90+ # Use httplib2 instead of urllib2 (or MAASDispatcher, which is based
91+ # on urllib2) so that we get full control over HTTP method. TODO:
92+ # create custom MAASDispatcher to use httplib2 so that MAASClient can
93+ # be used.
94+ response, content = http_request(
95+ uri, self.method, body=body, headers=headers,
96+ insecure=options.insecure)
97+
98+ # 2xx status codes are all okay.
99+ if response.status // 100 != 2:
100+ if options.debug:
101+ self.print_debug(response)
102+ self.print_response(response, content)
103+ raise CommandError(2)
104+ return content
105+
106+ def prepare_initial_payload(self, data):
107+ """Return the body and headers for the initial request.
108+
109+ This is method is only used for the first request to MAAS. It
110+ removes the passed content, and replaces it with the sha256 and size
111+ of that content.
112+
113+ :param data: An iterable of ``name, value`` or ``name, opener``
114+ tuples (see `name_value_pair`) to pack into the body or
115+ query, depending on the type of request.
116+ """
117+ data = dict(data)
118+ if 'content' not in data:
119+ print('Missing content.')
120+ raise CommandError(2)
121+
122+ content = data.pop('content')
123+ size, sha256 = self.calculate_size_and_sha256(content)
124+ data['size'] = '%s' % size
125+ data['sha256'] = sha256
126+
127+ data = [(key, value) for key, value in data.items()]
128+ message = build_multipart_message(data)
129+ headers, body = encode_multipart_message(message)
130+ return body, headers
131+
132+ def calculate_size_and_sha256(self, content):
133+ """Return the size and sha256 of the content."""
134+ size = 0
135+ sha256 = hashlib.sha256()
136+ with content() as fd:
137+ while True:
138+ buf = fd.read(CHUNK_SIZE)
139+ length = len(buf)
140+ size += length
141+ sha256.update(buf)
142+ if length != CHUNK_SIZE:
143+ break
144+ return size, sha256.hexdigest()
145+
146+ def get_resource_file(self, content):
147+ """Return the boot resource file for the created file."""
148+ data = json.loads(content)
149+ if len(data['sets']) == 0:
150+ # No sets returned, no way to get the resource file.
151+ return None
152+ newest_set = sorted(data['sets'].keys(), reverse=True)[0]
153+ resource_set = data['sets'][newest_set]
154+ if len(resource_set['files']) != 1:
155+ # This api only supports uploading one file. If the set doesn't
156+ # have just one file, then there is no way of knowing which file.
157+ return None
158+ _, rfile = resource_set['files'].popitem()
159+ return rfile
160+
161+ def put_upload(self, upload_uri, data, insecure=False):
162+ """Send PUT method to upload data."""
163+ headers = {
164+ 'Content-Type': 'application/octet-stream',
165+ 'Content-Length': '%s' % len(data),
166+ }
167+ if self.credentials is not None:
168+ self.sign(upload_uri, headers, self.credentials)
169+ # httplib2 expects the body to be file-like if its binary data
170+ data = StringIO(data)
171+ response, content = http_request(
172+ upload_uri, 'PUT', body=data, headers=headers,
173+ insecure=insecure)
174+ if response.status != 200:
175+ self.print_response(response, content)
176+ raise CommandError(2)
177+
178+ def upload_content(self, upload_uri, content, insecure=False):
179+ """Upload the content in chunks."""
180+ with content() as fd:
181+ while True:
182+ buf = fd.read(CHUNK_SIZE)
183+ length = len(buf)
184+ if length > 0:
185+ self.put_upload(upload_uri, buf, insecure=insecure)
186+ if length != CHUNK_SIZE:
187+ break
188+
189+
190+# Each action sets this variable so the class can be picked up
191+# by get_action_class.
192+action_class = BootResourcesCreateAction
193
194=== added directory 'src/maascli/actions/tests'
195=== added file 'src/maascli/actions/tests/__init__.py'
196=== added file 'src/maascli/actions/tests/test_boot_resources_create.py'
197--- src/maascli/actions/tests/test_boot_resources_create.py 1970-01-01 00:00:00 +0000
198+++ src/maascli/actions/tests/test_boot_resources_create.py 2014-09-12 03:32:27 +0000
199@@ -0,0 +1,207 @@
200+# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
201+# GNU Affero General Public License version 3 (see the file LICENSE).
202+
203+"""Test for boot-resources create action."""
204+
205+from __future__ import (
206+ absolute_import,
207+ print_function,
208+ unicode_literals,
209+ )
210+
211+str = None
212+
213+__metaclass__ = type
214+__all__ = []
215+
216+from functools import partial
217+import hashlib
218+import json
219+import os
220+from random import randint
221+
222+from apiclient.testing.credentials import make_api_credentials
223+import httplib2
224+from maascli.actions import boot_resources_create
225+from maascli.actions.boot_resources_create import (
226+ BootResourcesCreateAction,
227+ CHUNK_SIZE,
228+ )
229+from maascli.command import CommandError
230+from maastesting.factory import factory
231+from maastesting.fixtures import TempDirectory
232+from maastesting.matchers import MockCalledOnceWith
233+from maastesting.testcase import MAASTestCase
234+from mock import (
235+ ANY,
236+ Mock,
237+ sentinel,
238+ )
239+
240+
241+class TestBootResourcesCreateAction(MAASTestCase):
242+ """Tests for `BootResourcesCreateAction`."""
243+
244+ def configure_http_request(self, status, content):
245+ response = httplib2.Response({'status': status})
246+ self.patch(
247+ boot_resources_create,
248+ 'http_request').return_value = (response, content)
249+
250+ def make_boot_resources_create_action(self):
251+ action_name = "create".encode("ascii")
252+ action_bases = (BootResourcesCreateAction,)
253+ action_ns = {
254+ "action": {'method': 'POST'},
255+ "handler": {'uri': '/api/1.0/boot-resources/', 'params': []},
256+ "profile": {'credentials': make_api_credentials()}
257+ }
258+ action_class = type(action_name, action_bases, action_ns)
259+ action = action_class(Mock())
260+ self.patch(action, 'print_debug')
261+ self.patch(action, 'print_response')
262+ return action
263+
264+ def make_content(self, size=None):
265+ tmpdir = self.useFixture(TempDirectory()).path
266+ if size is None:
267+ size = randint(1024, 2048)
268+ data = factory.make_bytes(size)
269+ content_path = os.path.join(tmpdir, 'content')
270+ with open(content_path, 'wb') as stream:
271+ stream.write(data)
272+ sha256 = hashlib.sha256()
273+ sha256.update(data)
274+ return size, sha256.hexdigest(), partial(open, content_path, "rb")
275+
276+ def test_initial_request_returns_content(self):
277+ content = factory.make_name('content')
278+ self.configure_http_request(200, content)
279+ action = self.make_boot_resources_create_action()
280+ self.patch(action, 'prepare_initial_payload').return_value = ("", {})
281+ self.assertEqual(
282+ content, action.initial_request(sentinel.uri, Mock()))
283+
284+ def test_initial_request_raises_CommandError_on_error(self):
285+ self.configure_http_request(500, factory.make_name('content'))
286+ action = self.make_boot_resources_create_action()
287+ self.patch(action, 'prepare_initial_payload').return_value = ("", {})
288+ self.assertRaises(
289+ CommandError,
290+ action.initial_request, sentinel.uri, Mock())
291+
292+ def test_prepare_initial_payload_raises_CommandError_missing_content(self):
293+ action = self.make_boot_resources_create_action()
294+ self.patch(boot_resources_create, 'print')
295+ self.assertRaises(
296+ CommandError,
297+ action.prepare_initial_payload, [('invalid', '')])
298+
299+ def test_prepare_initial_payload_adds_size_and_sha256(self):
300+ size, sha256, stream = self.make_content()
301+ action = self.make_boot_resources_create_action()
302+ mock_build_message = self.patch(
303+ boot_resources_create,
304+ 'build_multipart_message')
305+ self.patch(
306+ boot_resources_create,
307+ 'encode_multipart_message').return_value = (None, None)
308+ action.prepare_initial_payload([('content', stream)])
309+ self.assertThat(
310+ mock_build_message,
311+ MockCalledOnceWith([('sha256', sha256), ('size', '%s' % size)]))
312+
313+ def test_get_resource_file_returns_None_when_no_sets(self):
314+ content = {
315+ 'sets': {}
316+ }
317+ action = self.make_boot_resources_create_action()
318+ self.assertIsNone(action.get_resource_file(json.dumps(content)))
319+
320+ def test_get_resource_file_returns_None_when_no_files(self):
321+ content = {
322+ 'sets': {
323+ '20140910': {
324+ 'files': {}
325+ },
326+ },
327+ }
328+ action = self.make_boot_resources_create_action()
329+ self.assertIsNone(action.get_resource_file(json.dumps(content)))
330+
331+ def test_get_resource_file_returns_None_when_more_than_one_file(self):
332+ content = {
333+ 'sets': {
334+ '20140910': {
335+ 'files': {
336+ 'root-image.gz': {},
337+ 'root-tgz': {},
338+ }
339+ },
340+ },
341+ }
342+ action = self.make_boot_resources_create_action()
343+ self.assertIsNone(action.get_resource_file(json.dumps(content)))
344+
345+ def test_get_resource_file_returns_file_from_newest_set(self):
346+ filename = factory.make_name('file')
347+ content = {
348+ 'sets': {
349+ '20140910': {
350+ 'files': {
351+ filename: {
352+ 'name': filename,
353+ },
354+ },
355+ },
356+ '20140909': {
357+ 'files': {
358+ 'other': {
359+ 'name': 'other',
360+ },
361+ },
362+ },
363+ },
364+ }
365+ action = self.make_boot_resources_create_action()
366+ self.assertEqual(
367+ {'name': filename},
368+ action.get_resource_file(json.dumps(content)))
369+
370+ def test_put_upload_raise_CommandError_if_status_not_200(self):
371+ self.configure_http_request(500, '')
372+ action = self.make_boot_resources_create_action()
373+ self.assertRaises(
374+ CommandError,
375+ action.put_upload, sentinel.upload_uri, '')
376+
377+ def test_put_upload_sends_content_type_and_length_headers(self):
378+ response = httplib2.Response({'status': 200})
379+ mock_request = self.patch(boot_resources_create, 'http_request')
380+ mock_request.return_value = (response, '')
381+ action = self.make_boot_resources_create_action()
382+ self.patch(action, 'sign')
383+ data = factory.make_bytes()
384+ action.put_upload(sentinel.upload_uri, data)
385+ headers = {
386+ 'Content-Type': 'application/octet-stream',
387+ 'Content-Length': '%s' % len(data),
388+ }
389+ self.assertThat(
390+ mock_request,
391+ MockCalledOnceWith(
392+ sentinel.upload_uri, 'PUT', body=ANY,
393+ headers=headers, insecure=False))
394+
395+ def test_upload_content_calls_put_upload_with_sizeof_CHUNK_SIZE(self):
396+ size = CHUNK_SIZE * 2
397+ size, sha256, stream = self.make_content(size=size)
398+ action = self.make_boot_resources_create_action()
399+ mock_upload = self.patch(action, 'put_upload')
400+ action.upload_content(sentinel.upload_uri, stream)
401+
402+ call_data_sizes = [
403+ len(call[0][1])
404+ for call in mock_upload.call_args_list
405+ ]
406+ self.assertEqual([CHUNK_SIZE, CHUNK_SIZE], call_data_sizes)
407
408=== modified file 'src/maascli/api.py'
409--- src/maascli/api.py 2014-08-13 21:49:35 +0000
410+++ src/maascli/api.py 2014-09-12 03:32:27 +0000
411@@ -53,6 +53,7 @@
412 handler_command_name,
413 parse_docstring,
414 safe_name,
415+ try_import_module,
416 )
417
418
419@@ -402,12 +403,32 @@
420 sys.exit(0)
421
422
423+def get_action_class(handler, action):
424+ """Return custom action handler class."""
425+ handler_name = handler_command_name(handler["name"]).replace('-', '_')
426+ action_name = '%s_%s' % (
427+ handler_name,
428+ safe_name(action["name"]).replace('-', '_'))
429+ action_module = try_import_module('maascli.actions.%s' % action_name)
430+ if action_module is not None:
431+ return action_module.action_class
432+ return None
433+
434+
435+def get_action_class_bases(handler, action):
436+ """Return the base classes for the dynamic class."""
437+ action_class = get_action_class(handler, action)
438+ if action_class is not None:
439+ return (action_class,)
440+ return (Action,)
441+
442+
443 def register_actions(profile, handler, parser):
444 """Register a handler's actions."""
445 for action in handler["actions"]:
446 help_title, help_body = parse_docstring(action["doc"])
447 action_name = safe_name(action["name"]).encode("ascii")
448- action_bases = (Action,)
449+ action_bases = get_action_class_bases(handler, action)
450 action_ns = {
451 "action": action,
452 "handler": handler,
453
454=== modified file 'src/maascli/tests/test_api.py'
455--- src/maascli/tests/test_api.py 2014-08-13 21:49:35 +0000
456+++ src/maascli/tests/test_api.py 2014-09-12 03:32:27 +0000
457@@ -24,6 +24,7 @@
458
459 import httplib2
460 from maascli import api
461+from maascli.actions.boot_resources_create import BootResourcesCreateAction
462 from maascli.command import CommandError
463 from maascli.config import ProfileConfig
464 from maascli.parser import ArgumentParser
465@@ -179,6 +180,34 @@
466 "disable the certificate check.")
467 self.assertEqual(error_expected, "%s" % error)
468
469+ def test_get_action_class_returns_None_for_unknown_handler(self):
470+ handler = {'name': factory.make_name('handler')}
471+ action = {'name': 'create'}
472+ self.assertIsNone(api.get_action_class(handler, action))
473+
474+ def test_get_action_class_returns_BootResourcesCreateAction_class(self):
475+ # Test uses BootResourcesCreateAction as its know to exist.
476+ handler = {'name': 'BootResourcesHandler'}
477+ action = {'name': 'create'}
478+ self.assertEqual(
479+ BootResourcesCreateAction,
480+ api.get_action_class(handler, action))
481+
482+ def test_get_action_class_bases_returns_Action(self):
483+ handler = {'name': factory.make_name('handler')}
484+ action = {'name': 'create'}
485+ self.assertEqual(
486+ (api.Action,),
487+ api.get_action_class_bases(handler, action))
488+
489+ def test_get_action_class_bases_returns_BootResourcesCreateAction(self):
490+ # Test uses BootResourcesCreateAction as its know to exist.
491+ handler = {'name': 'BootResourcesHandler'}
492+ action = {'name': 'create'}
493+ self.assertEqual(
494+ (BootResourcesCreateAction,),
495+ api.get_action_class_bases(handler, action))
496+
497
498 class TestIsResponseTextual(MAASTestCase):
499 """Tests for `is_response_textual`."""
500
501=== modified file 'src/maascli/utils.py'
502--- src/maascli/utils.py 2013-10-07 09:31:09 +0000
503+++ src/maascli/utils.py 2014-09-12 03:32:27 +0000
504@@ -25,6 +25,7 @@
505 getdoc,
506 )
507 import re
508+import sys
509 from urlparse import urlparse
510
511
512@@ -107,3 +108,17 @@
513 if re.search("/api/[0-9.]+/?$", url.path) is None:
514 url = url._replace(path=url.path + "api/1.0/")
515 return url.geturl()
516+
517+
518+def import_module(import_str):
519+ """Import a module."""
520+ __import__(import_str)
521+ return sys.modules[import_str]
522+
523+
524+def try_import_module(import_str, default=None):
525+ """Try to import a module."""
526+ try:
527+ return import_module(import_str)
528+ except ImportError:
529+ return default
530
531=== modified file 'src/maasserver/api/boot_resources.py'
532--- src/maasserver/api/boot_resources.py 2014-09-03 01:05:32 +0000
533+++ src/maasserver/api/boot_resources.py 2014-09-12 03:32:27 +0000
534@@ -273,12 +273,14 @@
535
536 read = create = delete = None
537
538+ hidden = True
539+
540 @admin_method
541 def update(self, request, id, file_id):
542 """Upload piece of boot resource file."""
543 resource = get_object_or_404(BootResource, id=id)
544 rfile = get_object_or_404(BootResourceFile, id=file_id)
545- size = request.META.get('CONTENT_LENGTH', 0)
546+ size = int(request.META.get('CONTENT_LENGTH', '0'))
547 data = request.body
548 if size == 0:
549 raise MAASAPIBadRequest("Missing data.")
550
551=== modified file 'src/maasserver/api/boot_source_selections.py'
552--- src/maasserver/api/boot_source_selections.py 2014-08-22 15:21:48 +0000
553+++ src/maasserver/api/boot_source_selections.py 2014-09-12 03:32:27 +0000
554@@ -101,7 +101,7 @@
555 be set globally for the whole region and clusters. This api is now
556 deprecated, and only exists for backwards compatibility.
557 """
558- deprecated = True
559+ hidden = True
560
561 def read(self, request, uuid, boot_source_id, id):
562 """Read a boot source selection."""
563@@ -175,7 +175,7 @@
564 be set globally for the whole region and clusters. This api is now
565 deprecated, and only exists for backwards compatibility.
566 """
567- deprecated = True
568+ hidden = True
569
570 def read(self, request, uuid, boot_source_id):
571 """List boot source selections.
572
573=== modified file 'src/maasserver/api/boot_sources.py'
574--- src/maasserver/api/boot_sources.py 2014-08-22 15:21:48 +0000
575+++ src/maasserver/api/boot_sources.py 2014-09-12 03:32:27 +0000
576@@ -119,7 +119,7 @@
577 be set globally for the whole region and clusters. This api is now
578 deprecated, and only exists for backwards compatibility.
579 """
580- deprecated = True
581+ hidden = True
582
583 def read(self, request, uuid, id):
584 """Read a boot source."""
585@@ -186,7 +186,7 @@
586 be set globally for the whole region and clusters. This api is now
587 deprecated, and only exists for backwards compatibility.
588 """
589- deprecated = True
590+ hidden = True
591
592 def read(self, request, uuid):
593 """List boot sources.
594
595=== modified file 'src/maasserver/api/doc.py'
596--- src/maasserver/api/doc.py 2014-08-22 15:21:48 +0000
597+++ src/maasserver/api/doc.py 2014-09-12 03:32:27 +0000
598@@ -43,12 +43,14 @@
599 Handlers are of type :class:`HandlerMetaClass`, and must define a
600 `resource_uri` method.
601
602+ Handlers that have the attribute hidden set to True, will not be returned.
603+
604 :rtype: Generator, yielding handlers.
605 """
606 p_has_resource_uri = lambda resource: (
607 getattr(resource.handler, "resource_uri", None) is not None)
608- p_is_not_deprecated = lambda resource: (
609- getattr(resource.handler, "deprecated", False))
610+ p_is_not_hidden = lambda resource: (
611+ getattr(resource.handler, "hidden", False))
612 for pattern in resolver.url_patterns:
613 if isinstance(pattern, RegexURLResolver):
614 accumulate_api_resources(pattern, accumulator)
615@@ -56,7 +58,7 @@
616 if isinstance(pattern.callback, Resource):
617 resource = pattern.callback
618 if p_has_resource_uri(resource) and \
619- not p_is_not_deprecated(resource):
620+ not p_is_not_hidden(resource):
621 accumulator.add(resource)
622 else:
623 raise AssertionError(
624
625=== modified file 'src/maasserver/api/tests/test_doc.py'
626--- src/maasserver/api/tests/test_doc.py 2014-08-20 23:54:33 +0000
627+++ src/maasserver/api/tests/test_doc.py 2014-09-12 03:32:27 +0000
628@@ -88,6 +88,18 @@
629 module.urlpatterns = patterns("", url("^metal", resource))
630 self.assertSetEqual({resource}, find_api_resources(module))
631
632+ def test_urlpatterns_with_resource_hidden(self):
633+ # Resources for handlers with resource_uri attributes are discovered
634+ # in a urlconf module and returned, unless hidden = True.
635+ handler = type(b"\m/", (BaseHandler,), {
636+ "resource_uri": True,
637+ "hidden": True,
638+ })
639+ resource = Resource(handler)
640+ module = self.make_module()
641+ module.urlpatterns = patterns("", url("^metal", resource))
642+ self.assertSetEqual(set(), find_api_resources(module))
643+
644 def test_nested_urlpatterns_with_handler(self):
645 # Resources are found in nested urlconfs.
646 handler = type(b"\m/", (BaseHandler,), {"resource_uri": True})
647
648=== modified file 'src/maasserver/models/bootresourcefile.py'
649--- src/maasserver/models/bootresourcefile.py 2014-08-31 02:47:48 +0000
650+++ src/maasserver/models/bootresourcefile.py 2014-09-12 03:32:27 +0000
651@@ -20,6 +20,8 @@
652 CharField,
653 ForeignKey,
654 )
655+from django.db.models.signals import post_delete
656+from django.dispatch import receiver
657 from maasserver import DefaultMeta
658 from maasserver.enum import (
659 BOOT_RESOURCE_FILE_TYPE,
660@@ -70,3 +72,16 @@
661
662 def __repr__(self):
663 return "<BootResourceFile %s/%s>" % (self.filename, self.filetype)
664+
665+
666+@receiver(post_delete)
667+def delete_large_file(sender, instance, **kwargs):
668+ """Call delete on the LargeFile, now that the relation has been removed.
669+ If this was the only resource file referencing this LargeFile then it will
670+ be delete.
671+
672+ This is done using the `post_delete` signal because only then has the
673+ relation been removed.
674+ """
675+ if sender == BootResourceFile:
676+ instance.largefile.delete()